Skip to content

perf(tools): prefetch streamed filesystem calls - #445

Open
mpscholten wants to merge 50 commits into
masterfrom
2026-08-25-speculative-read-file
Open

perf(tools): prefetch streamed filesystem calls#445
mpscholten wants to merge 50 commits into
masterfrom
2026-08-25-speculative-read-file

Conversation

@mpscholten

@mpscholten mpscholten commented Aug 25, 2026

Copy link
Copy Markdown
Member

Summary

  • add a provider-neutral streamed tool interpreter to the loop, with the completed ToolCall remaining authoritative
  • overlap filesystem work with the remaining argument stream for read_file, list_dir, search_replace, and apply_patch
  • preserve ordinary approval, resource scheduling, rich success/failure classification, retry, and cancellation semantics
  • fall back to normal execution on an incomplete or ambiguous path, late completion, stale data, changed arguments, denied access, parser failure, or cancellation
  • share path-prefix, fingerprint, Git-index, bounded-concurrency, cancellation, and formatting helpers rather than duplicating them in each tool

Design

Every tool now runs through a small StreamedTool fold:

data ToolInput = ToolPrefix Text | ToolDone Text

data StreamedTool = forall s args. StreamedTool
    { streamedStart :: IO s
    , streamedInterpret :: s -> ToolInput -> IO (Either (args, s) s)
    , streamedConsume :: ToolCall -> (Text -> IO ()) -> args -> s -> IO ToolResult
    , streamedClose :: s -> IO ()
    }

The provider adapter emits provider-neutral argument events. ToolSpeculationRuntime correlates provider item IDs, output indexes, and call IDs, owns one scoped worker per call, and retains work only for calls in the final response. The final call is checked before a prepared result can be consumed.

Filesystem interpreters can start only when a path is complete or a streamed prefix uniquely identifies a Git-indexed workspace path. Prepared reads are bounded (four concurrent files, 16 MiB maximum), preserve the normal PDF/NUL checks, and are revalidated with a file fingerprint immediately before use. Approval and resource claims still run before consumption, including external-root access.

Correctness audit

This revision fixes and covers:

  • positive subranges within a prefetched default 1,000-line window
  • negative offsets and ranges outside the prefetched window falling back instead of returning a false hit
  • avoiding a read beyond the selected 1,000th newline
  • matching normal binary detection by checking the first 8 KiB, including a NUL later in a larger file
  • changed/replaced files and final arguments invalidating prepared results
  • a denied external path prompting exactly once
  • retries, conflicting stream aliases, abandoned calls, reset/shutdown cancellation, and bounded worker capacity
  • semantic handler failures remaining failures even when rendered text is customized
  • repeated selected native-agent output staying within its configured cap

The edge cases are covered in dedicated speculation, interpreter, filesystem, loop, dialect, provider, and CLI tests.

Benchmarks

Built with GHC 9.10.3 and -O2; each result is forced and measured with +RTS -T -N1. Fixtures are created outside the measured interval, while the configured tail deliberately models argument-streaming time still available for overlap. All compared modes produced identical checksums.

nix develop -c cabal build --offline --enable-optimization=2 \
  agent-core:bench:speculative-read-file-bench \
  agent-cli:bench:speculative-tools-bench

Isolated read_file

15 samples with a 10 ms remaining stream tail:

Mode File Wall median CPU median Allocation Hits / misses
baseline 1 MiB 11.386 ms 1.240 ms 4,134,000 B 0 / 0
completed speculation 1 MiB 10.383 ms 1.663 ms 4,361,104 B 15 / 0
late prefix / fallback 1 MiB 11.842 ms 1.979 ms 5,358,792 B 0 / 15
baseline 8 MiB 17.856 ms 7.682 ms 30,680,312 B 0 / 0
completed speculation 8 MiB 10.331 ms 7.744 ms 30,907,544 B 15 / 0
late prefix / fallback 8 MiB 17.868 ms 7.968 ms 31,906,320 B 0 / 15

Completed speculation reduced median wall time by 8.8% at 1 MiB and 42.1% at 8 MiB. The representative 8 MiB miss path remained effectively at baseline.

Mixed coding turn

The benchmark replays list_dir, four read_file calls, search_replace, and apply_patch against 8 MiB edit targets (31 samples):

Run Tail Baseline p50 Speculative p50 Change
primary 10 ms 64.939 ms 59.242 ms 8.8% faster
longer overlap 50 ms 109.452 ms 100.515 ms 8.2% faster
repeat 10 ms 71.714 ms 58.217 ms 18.8% faster

Speculation allocated about 2.9 MiB more per mixed turn (roughly 2.9%). Median CPU varied from +0.3 ms to +7.8 ms, as expected for work moved ahead and overlapped rather than eliminated.

Validation

  • synchronized with master at cd37be69; it is an ancestor of head ea29944f
  • exact 14-library multi-package GHCi load: 450 modules
  • optimized tests: 2,948 examples, 0 failures, 1 expected pending live-auth example
    • agent-core: 542
    • agent-cli: 1,459
    • agent-tui: 195
    • Codex/Grok dialects: 15 / 49
    • Responses/Gemini/MCP/Claude SDK/OpenAI/agent-claude: 83 / 48 / 95 / 91 / 326 / 45
  • packages/agent-core/package.nix regenerated and checked
  • git diff --check
@mpscholten
mpscholten force-pushed the 2026-08-25-speculative-read-file branch 2 times, most recently from d434b09 to d5aa4f8 Compare August 25, 2026 08:39
@mpscholten
mpscholten force-pushed the 2026-08-25-speculative-read-file branch from d5aa4f8 to 77d2ea0 Compare August 25, 2026 11:36
@mpscholten mpscholten changed the title perf(openai): prefetch streamed read_file calls Aug 25, 2026
@mpscholten
mpscholten force-pushed the 2026-08-25-speculative-read-file branch from 77d2ea0 to 136ba3f Compare August 25, 2026 13:18
Keep the Runtime.hs facade and port speculative-read-file wiring onto
Runtime/Internal.hs. Combine OpenAI WebSocket speculation with the
HTTP transport fallback and Codex turn-state recovery.
Replace the callback-based speculation API with a fold over argument
text:

  type ToolInterpreter s = s -> ToolInput -> IO (Either ToolResult s)

The runtime still correlates provider events, but tools only see
accumulated prefixes and one ToolDone after approval. Speculation is
now an implementation detail of read_file rather than a core protocol.
ToolInterpreter now returns Either (args, state) state. After approval,
streamedConsume runs on those parsed args so later stages do not
re-decode JSON. read_file uses ReadFileArgs for prefetch consume and
live fallback.
…ive-read-file

# Conflicts:
#	packages/agent-cli/src/Agent/CLI/Runtime/Internal.hs
jsonTool, raw JSON tools, and apply_patch now attach a default
interpreter that decodes arguments once on ToolDone and runs the
typed handler. Resource claims take those parsed records via
withTypedResourceClaims. read_file still replaces the default with
its prefetching interpreter. Streaming tools keep snapshot output
through takeToolSpeculationEmitting.
…ive-read-file

# Conflicts:
#	packages/agent-cli/src/Agent/CLI/Provider/OpenAI.hs
#	packages/agent-core/agent-core.cabal
#	packages/agent-core/package.nix
Stop threading ToolSpeculationRuntime through CLI session types, the
OpenAI backend constructors, and LoopConfig. The loop now builds the
runtime from the tool registry, backends only emit ToolArgumentEvent,
and --speculative-read-file is gone. Prefetch is an interpreter detail
again, not a session feature.
The runtime now always opens a streamed interpreter, defaulting to the
typed handler when a tool does not supply its own factory. JSON tools
decode on prefixes as well as ToolDone. MCP and test AppTool records
use the same default. A QuickCheck suite covers chunked prefixes,
handler-once consume, misses, call-aware tools, and snapshot emit.
list_dir predicts unique directories from streamed target_directory
prefixes. search_replace and apply_patch prefetch the target file
read while later arguments still stream, then write only after
approval. Shared path/fingerprint helpers live in PathPrefix and
FilePrefetch.
Add speculative-tools-bench: a sequential, LLM-free replay of list_dir,
read_file, search_replace, and apply_patch against a unique-prefix
fixture. Baseline waits out the stream tail then dispatches; speculative
feeds prefixes so reads overlap that tail. Reports p50/p95 wall.
Prefetch no longer stores raw bytes plus a formatted copy. Default
offset/limit reads stop after 1000 lines, and prefix JSON is not run
through Aeson. Negative offsets still slurp so last-line seeks work.
Replace remaining Aeson.decodeStrict' in PathPrefix and
read_file speculation so complete objects and JSON string
unescaping go through Agent.Json.Decode.
Honor an explicit read_file limit so a full-file read is possible.
The mixed replay now sizes edit payloads from FILE_KIB, keeps reads
under the token cap, and reports io_* as wall minus the tail delay.
…29-105db0e9

# Conflicts:
#	packages/agent-claude/src/Agent/Claude/Internal/Messages.hs
#	packages/agent-codex-dialect/src/Agent/Codex/Dialect/Tools.hs
#	packages/agent-codex-dialect/test/Agent/Codex/DialectSpec.hs
#	packages/agent-core/agent-core.cabal
#	packages/agent-core/package.nix
#	packages/agent-core/src/Agent/Loop/Internal.hs
#	packages/agent-core/src/Agent/MCP/Client.hs
#	packages/agent-core/test/Spec.hs
#	packages/agent-openai/src/Agent/OpenAI/LoopBackend.hs
#	packages/agent-openai/test/Agent/OpenAI/LoopBackendSpec.hs
# Conflicts:
#	packages/agent-core/src/Agent/Loop/Internal.hs
…29-105db0e9

# Conflicts:
#	packages/agent-cli/src/Agent/CLI/NativeAgents.hs
#	packages/agent-cli/src/Agent/CLI/Provider/OpenAI.hs
#	packages/agent-codex-dialect/test/Agent/Codex/DialectSpec.hs
#	packages/agent-core/package.nix
#	packages/agent-core/src/Agent/Tools/FileSystem/ReadFile.hs
# Conflicts:
#	packages/agent-core/src/Agent/Loop/Internal.hs
#	packages/agent-core/src/Agent/ToolDispatch.hs
@mpscholten mpscholten changed the title perf(tools): prefetch streamed read_file calls Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant