Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions agent/moon.pkg
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
"bobzhang/openseek/agent_tool",
"bobzhang/openseek/agent_tool/edit",
"bobzhang/openseek/agent_tool/finish",
"bobzhang/openseek/agent_tool/read_state",
"bobzhang/openseek/agent_tool/moon_check",
"bobzhang/openseek/agent_tool/read",
"bobzhang/openseek/agent_tool/shell",
Expand Down
8 changes: 6 additions & 2 deletions agent/tool_definition.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,15 @@ fn[X] tool_definitions(
// `moon check` through shell).
ignore(scope)
let workspace_root = runtime.workspace_root()
// One seen-set shared by the file tools for this session: `read` and `edit`
// record what the agent has looked at, and `write` uses it to flag an
// overwrite of a file that was never read this session.
let read_state = @read_state.ReadState::ReadState()
Tools([
@shell.definition(workspace_root~),
@read.definition(workspace_root~),
@read.definition(workspace_root~, read_state~),
@edit.definition(workspace_root~),
@write.definition(workspace_root~),
@write.definition(workspace_root~, read_state~),
@finish.definition(),
])
}
Expand Down
1 change: 1 addition & 0 deletions agent_tool/read/moon.pkg
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
"bobzhang/openseek/agent_tool",
"bobzhang/openseek/agent_tool/internal/error" @tool_error,
"bobzhang/openseek/agent_tool/read_state",
"bobzhang/openseek/agent_tool/read/internal/decode",
"bobzhang/openseek/internal/workspace_path",
"bobzhang/openseek/testkit/filesystem" @vfs,
Expand Down
3 changes: 2 additions & 1 deletion agent_tool/read/pkg.generated.mbti
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ package "bobzhang/openseek/agent_tool/read"

import {
"bobzhang/openseek/agent_tool",
"bobzhang/openseek/agent_tool/read_state",
}

// Values
pub fn definition(workspace_root? : String) -> @agent_tool.AgentToolDefinition
pub fn definition(workspace_root? : String, read_state? : @read_state.ReadState) -> @agent_tool.AgentToolDefinition

// Errors

Expand Down
66 changes: 63 additions & 3 deletions agent_tool/read/read.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
/// invalid arguments.
pub fn definition(
workspace_root? : String = ".",
read_state? : @read_state.ReadState,
) -> @agent_tool.AgentToolDefinition {
AgentToolDefinition(
name="read",
Expand All @@ -49,18 +50,21 @@ pub fn definition(
"max_output_chars": { "type": "number" },
},
}),
execute=Async(arguments => execute_with_workspace(workspace_root, arguments)),
execute=Async(arguments => {
execute_with_workspace(workspace_root, read_state, arguments)
}),
)
}

///|
async fn execute(arguments : Json) -> @agent_tool.ToolAction {
execute_with_workspace(".", arguments)
execute_with_workspace(".", None, arguments)
}

///|
async fn execute_with_workspace(
workspace_root : String,
read_state : @read_state.ReadState?,
arguments : Json,
) -> @agent_tool.ToolAction {
let input = @decode.decode(arguments) catch {
Expand All @@ -72,7 +76,11 @@ async fn execute_with_workspace(
)
}
}
read_file(input, path=@workspace_path.resolve(workspace_root, input.path))
read_file(
input,
path=@workspace_path.resolve(workspace_root, input.path),
read_state~,
)
}

///|
Expand All @@ -90,10 +98,20 @@ priv struct RenderedSelection {
truncated : Bool
}

///|
/// Record `path` as seen this session, when read-state tracking is wired in.
fn record_seen(read_state : @read_state.ReadState?, path : String) -> Unit {
match read_state {
Some(state) => state.record(path)
None => ()
}
}

///|
async fn read_file(
input : @decode.ReadInput,
path~ : String,
read_state~ : @read_state.ReadState?,
) -> @agent_tool.ToolAction {
let brief = "read \{@agent_tool.brief_basename(path)}"
match directory_read_error(path) {
Expand All @@ -114,6 +132,9 @@ async fn read_file(
// containing only a newline is not empty (it has blank lines) and renders
// normally.
if content == "" {
// An empty file has no content the agent could miss, so it counts as fully
// seen for the purpose of the unread-overwrite check.
record_seen(read_state, path)
return @agent_tool.ToolAction::respond(
"<system>start_line=\{input.start_line} shown_lines=0 total_lines=0 truncated=false note=empty file</system>",
is_error=false,
Expand All @@ -122,6 +143,15 @@ async fn read_file(
}
let selection = select_lines(content, input.start_line, input.max_lines)
let rendered = render_numbered_content(selection, input.max_output_chars)
// Record the file as seen only when this read showed the agent the whole file:
// every line was selected (`shown_lines == total_lines`, so the range started
// at the top and was not cut short — a high `max_lines` that still covers the
// file counts) and the output budget did not truncate the body. A sliced or
// truncated read leaves content unseen, so a later `write` must still be
// flagged as an unread overwrite.
if selection.shown_lines == selection.total_lines && !rendered.truncated {
record_seen(read_state, path)
}
// Every read has the same shape: numbered lines followed by a single status
// footer. When the body is empty (start past EOF, or a zero budget) only the
// footer is returned, so the model still learns what it asked for.
Expand Down Expand Up @@ -582,6 +612,36 @@ async test "read rejects non-object arguments" {
assert_true(output.is_error)
}

///|
async test "read marks a file seen only on a complete read" {
@vfs.with_tmpdir(dir => {
let path = "\{dir}/notes.txt"
@fs.write_file(path, "a\nb\nc\nd\n", create_mode=CreateOrTruncate)
// A sliced read (max_lines) leaves most of the file unseen, so it must NOT
// be recorded — a later overwrite should still be flagged.
let sliced = @read_state.ReadState::ReadState()
guard definition(read_state=sliced).execute is Async(read_sliced)
let _ = read_sliced({ "path": path, "max_lines": 1 })
assert_false(sliced.has_seen(path))
// A read from a later line is likewise partial.
let offset = @read_state.ReadState::ReadState()
guard definition(read_state=offset).execute is Async(read_offset)
let _ = read_offset({ "path": path, "start_line": 2 })
assert_false(offset.has_seen(path))
// A complete read (default args, untruncated) records the file as seen.
let full = @read_state.ReadState::ReadState()
guard definition(read_state=full).execute is Async(read_full)
let _ = read_full({ "path": path })
assert_true(full.has_seen(path))
// A bounded read whose max_lines comfortably covers the file is still a full
// view, so it also counts as seen.
let bounded = @read_state.ReadState::ReadState()
guard definition(read_state=bounded).execute is Async(read_bounded)
let _ = read_bounded({ "path": path, "max_lines": 1000 })
assert_true(bounded.has_seen(path))
})
}

///|
async test "a huge max_lines returns the remaining lines without overflow" {
@vfs.with_tmpdir(dir => {
Expand Down
1 change: 1 addition & 0 deletions agent_tool/read_state/moon.pkg
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
warnings = "+unnecessary_annotation"
19 changes: 19 additions & 0 deletions agent_tool/read_state/pkg.generated.mbti
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Generated using `moon info`, DON'T EDIT IT
package "bobzhang/openseek/agent_tool/read_state"

// Values

// Errors

// Types and methods
pub struct ReadState {
seen : Map[String, Unit]
}
pub fn ReadState::ReadState() -> Self
pub fn ReadState::has_seen(Self, String) -> Bool
pub fn ReadState::record(Self, String) -> Unit

// Type aliases

// Traits

39 changes: 39 additions & 0 deletions agent_tool/read_state/read_state.mbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
///|
/// Session-scoped record of which workspace files the agent has already seen
/// this run — read, written, or edited. `write` consults it to flag when it
/// overwrites a file the agent never read, so a wholesale replacement of unseen
/// content is at least visible in the transcript.
///
/// It is ephemeral runtime state shared across the file tools, never persisted:
/// a resumed session starts empty, which fails safe (the agent is nudged to
/// read before overwriting). Keys are the already-resolved paths the tools
/// operate on — each tool resolves `arguments.path` under the workspace root
/// before recording or checking — so the same file maps to the same key across
/// tools.
///
/// This deliberately tracks only "seen vs not seen", not modification times:
/// the "file changed since you read it" check is for editors/linters touching
/// files concurrently, which does not happen in a single batch CLI session.
pub struct ReadState {
seen : Map[String, Unit]
}

///|
/// A fresh, empty seen-set. The agent loop creates one per session and hands it
/// to the read/write/edit tools.
pub fn ReadState::ReadState() -> ReadState {
{ seen: {} }
}

///|
/// Record that the agent has seen `path` (call after a successful read, write,
/// or edit). Idempotent.
pub fn ReadState::record(self : ReadState, path : String) -> Unit {
self.seen[path] = ()
}

///|
/// Whether `path` has been read, written, or edited this session.
pub fn ReadState::has_seen(self : ReadState, path : String) -> Bool {
self.seen.get(path) is Some(_)
}
11 changes: 11 additions & 0 deletions agent_tool/read_state/read_state_test.mbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
///|
test "ReadState records and recalls seen paths" {
let state = @read_state.ReadState::ReadState()
assert_false(state.has_seen("/ws/parser.mbt"))
state.record("/ws/parser.mbt")
assert_true(state.has_seen("/ws/parser.mbt"))
// Unrelated paths stay unseen; recording is idempotent.
assert_false(state.has_seen("/ws/types.mbt"))
state.record("/ws/parser.mbt")
assert_true(state.has_seen("/ws/parser.mbt"))
}
1 change: 1 addition & 0 deletions agent_tool/write/moon.pkg
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
"bobzhang/openseek/agent_tool",
"bobzhang/openseek/agent_tool/internal/auto_check",
"bobzhang/openseek/agent_tool/internal/error" @tool_error,
"bobzhang/openseek/agent_tool/read_state",
"bobzhang/openseek/agent_tool/write/internal/decode",
"bobzhang/openseek/internal/workspace_path",
"bobzhang/openseek/testkit/filesystem" @vfs,
Expand Down
3 changes: 2 additions & 1 deletion agent_tool/write/pkg.generated.mbti
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ package "bobzhang/openseek/agent_tool/write"

import {
"bobzhang/openseek/agent_tool",
"bobzhang/openseek/agent_tool/read_state",
}

// Values
pub fn definition(workspace_root? : String) -> @agent_tool.AgentToolDefinition
pub fn definition(workspace_root? : String, read_state? : @read_state.ReadState) -> @agent_tool.AgentToolDefinition

// Errors

Expand Down
Loading