diff --git a/README.md b/README.md index 0d55cce..c2edb3a 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,20 @@ printf "%s" "$TASK" | nanoPyCodeAgent The task is carried out in the current directory. `--max-turns N` caps how many model replies one run may spend (50 by default). +Add `--trajectory PATH` to save that headless Agent Run as one complete +[ATIF-v1.7](https://www.harborframework.com/docs/agents/trajectory-format) +JSON document without changing stdout: + +```bash +nanoPyCodeAgent -p "read README.md and summarize it" \ + --trajectory ./trajectory.json +``` + +The option creates the requested file with owner-only permissions (`0600`) +after the run reaches a terminal state. It refuses to overwrite an existing +path. Trajectories may contain the task, model replies, tool arguments, and +tool results, so treat them as sensitive data. + A run like this exits `0` whenever the agent actually ran — including when it gave up or ran out of turns with the task unfinished, which is for whatever checks the result to judge. A non-zero exit means the run could not happen at diff --git a/README.zh-CN.md b/README.zh-CN.md index bce80d9..97b431d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -45,6 +45,19 @@ printf "%s" "$TASK" | nanoPyCodeAgent 任务在当前目录下执行。`--max-turns N` 限制一次运行最多花费多少轮模型回复(默认 50 轮)。 +增加 `--trajectory PATH` 可以把这次 headless Agent Run 保存为一份完整的 +[ATIF-v1.7](https://www.harborframework.com/docs/agents/trajectory-format) JSON +文档,同时不改变 stdout: + +```bash +nanoPyCodeAgent -p "read README.md and summarize it" \ + --trajectory ./trajectory.json +``` + +run 到达终态后,该选项才会以仅当前用户可读写的权限(`0600`)创建目标文件;如果 +目标已存在则拒绝覆盖。trajectory 可能包含任务、模型回复、工具参数和工具结果, +应按敏感数据处理。 + 只要 agent 真的跑起来了,退出码就是 `0`——包括它放弃了、或者轮数用尽而任务没做 完,那该由检查结果的一方去判定。非零退出码表示这次运行根本没能进行:`1` 是缺少 凭据或 API 持续失败,`2` 是命令行用错了。 diff --git a/benchmarks/harbor/pyproject.toml b/benchmarks/harbor/pyproject.toml index 40b0b89..b292950 100644 --- a/benchmarks/harbor/pyproject.toml +++ b/benchmarks/harbor/pyproject.toml @@ -16,8 +16,12 @@ packages = ["src/harbor_adapter"] [dependency-groups] dev = [ + "nanopycodeagent", "pytest>=9.1.1", ] +[tool.uv.sources] +nanopycodeagent = { path = "../..", editable = true } + [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/benchmarks/harbor/tests/fixtures/atif-journal-v1.jsonl b/benchmarks/harbor/tests/fixtures/atif-journal-v1.jsonl new file mode 100644 index 0000000..d9a56da --- /dev/null +++ b/benchmarks/harbor/tests/fixtures/atif-journal-v1.jsonl @@ -0,0 +1,7 @@ +{"schema_version":1,"run_id":"run-harbor-atif","seq":1,"recorded_at":"2026-08-26T10:00:00.001Z","type":"run.started","payload":{"mode":"headless","model":"test-model","max_turns":10,"producer":{"name":"nanoPyCodeAgent","version":"0.8.0"},"source_timestamp":"2026-08-26T10:00:00.000Z"}} +{"schema_version":1,"run_id":"run-harbor-atif","seq":2,"recorded_at":"2026-08-26T10:00:00.002Z","type":"user.message","payload":{"message_id":"user-1","content":"read README.md","source_timestamp":"2026-08-26T10:00:00.010Z"}} +{"schema_version":1,"run_id":"run-harbor-atif","seq":3,"recorded_at":"2026-08-26T10:00:00.003Z","type":"model.started","payload":{"model_call_id":"model-1","model":"test-model","source_timestamp":"2026-08-26T10:00:00.020Z"}} +{"schema_version":1,"run_id":"run-harbor-atif","seq":4,"recorded_at":"2026-08-26T10:00:00.004Z","type":"model.completed","payload":{"model_call_id":"model-1","message_id":"msg-1","content":[{"type":"text","text":"checking"},{"type":"tool_call","tool_call_id":"call-1","tool_name":"read","input":{"path":"README.md"}}],"tool_calls":[{"type":"tool_call","tool_call_id":"call-1","tool_name":"read","input":{"path":"README.md"}}],"model":"test-model","stop_reason":"tool_use","usage":{"input_tokens":10,"output_tokens":2,"cache_read_input_tokens":3,"cache_creation_input_tokens":1},"provider_response_id":"msg-1","generation_id":null,"duration_ms":15,"source_timestamp":"2026-08-26T10:00:00.030Z"}} +{"schema_version":1,"run_id":"run-harbor-atif","seq":5,"recorded_at":"2026-08-26T10:00:00.005Z","type":"tool.started","payload":{"model_call_id":"model-1","tool_call_id":"call-1","tool_name":"read","input":{"path":"README.md"},"source_timestamp":"2026-08-26T10:00:00.040Z"}} +{"schema_version":1,"run_id":"run-harbor-atif","seq":6,"recorded_at":"2026-08-26T10:00:00.006Z","type":"tool.completed","payload":{"model_call_id":"model-1","tool_call_id":"call-1","tool_name":"read","result":"contents","is_error":false,"duration_ms":4,"source_timestamp":"2026-08-26T10:00:00.050Z"}} +{"schema_version":1,"run_id":"run-harbor-atif","seq":7,"recorded_at":"2026-08-26T10:00:00.007Z","type":"run.completed","payload":{"outcome":"completed","duration_ms":60,"source_timestamp":"2026-08-26T10:00:00.060Z"}} diff --git a/benchmarks/harbor/tests/test_atif_compatibility.py b/benchmarks/harbor/tests/test_atif_compatibility.py new file mode 100644 index 0000000..2280ac1 --- /dev/null +++ b/benchmarks/harbor/tests/test_atif_compatibility.py @@ -0,0 +1,15 @@ +"""Compatibility checks against Harbor's pinned ATIF-v1.7 validator.""" + +from pathlib import Path + +from harbor.utils.trajectory_validator import TrajectoryValidator +from nanopycodeagent.atif import project_atif +from nanopycodeagent.event_journal import EventJournal + + +def test_projector_output_passes_harbor_atif_validator(): + journal_path = Path(__file__).parent / "fixtures" / "atif-journal-v1.jsonl" + trajectory = project_atif(EventJournal.replay(journal_path)) + validator = TrajectoryValidator() + + assert validator.validate(trajectory), validator.get_errors() diff --git a/benchmarks/harbor/uv.lock b/benchmarks/harbor/uv.lock index eb24c88..1b7f85c 100644 --- a/benchmarks/harbor/uv.lock +++ b/benchmarks/harbor/uv.lock @@ -122,6 +122,24 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] +[[package]] +name = "anthropic" +version = "1.0.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "anyio" }, + { name = "docstring-parser" }, + { name = "httpx2" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/25/aa/4978e58035bd6c638c7b483450a68b7ef2d732ab78885e27bb9db0cff1a2/anthropic-1.0.0.tar.gz", hash = "sha256:42be3c97604af7252c5898413aee076ace6c46e9bca0d0d90ceb77c7d3719027", size = 1077769, upload-time = "2026-08-20T19:59:00.565Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ad/5b/db4a854aebf5d33a5ab714c46af6eb85ee44f390ed29b7b325c00b9f11ed/anthropic-1.0.0-py3-none-any.whl", hash = "sha256:32dd52e9e1d774393b27182f451398ba4262287a4d0eab30887f89f1481b3ae4", size = 1171725, upload-time = "2026-08-20T19:58:58.725Z" }, +] + [[package]] name = "anyio" version = "4.14.2" @@ -444,6 +462,15 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "fastapi" version = "0.141.1" @@ -681,6 +708,19 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.12.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -701,6 +741,31 @@ http2 = [ { name = "h2" }, ] +[[package]] +name = "httpx2" +version = "2.12.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + [[package]] name = "huggingface-hub" version = "1.28.0" @@ -1033,6 +1098,23 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] +[[package]] +name = "nanopycodeagent" +source = { editable = "../../" } +dependencies = [ + { name = "anthropic" }, + { name = "httpx" }, +] + +[package.metadata] +requires-dist = [ + { name = "anthropic", specifier = ">=0.112.0" }, + { name = "httpx", specifier = ">=0.25.0" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.1.1" }] + [[package]] name = "nanopycodeagent-harbor-adapter" version = "0.1.0" @@ -1043,6 +1125,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "nanopycodeagent" }, { name = "pytest" }, ] @@ -1050,7 +1133,10 @@ dev = [ requires-dist = [{ name = "harbor", specifier = "==0.21.0" }] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=9.1.1" }] +dev = [ + { name = "nanopycodeagent", editable = "../../" }, + { name = "pytest", specifier = ">=9.1.1" }, +] [[package]] name = "openai" @@ -1811,6 +1897,15 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typer" version = "0.27.1" diff --git a/docs/changelogs/0.8.x.md b/docs/changelogs/0.8.x.md index 6561294..08d62d9 100644 --- a/docs/changelogs/0.8.x.md +++ b/docs/changelogs/0.8.x.md @@ -32,6 +32,11 @@ All notable changes in the **0.8.x** release series are documented here. response metadata, and explicit truncation metadata. Journals are stored as sensitive user-only files while the existing text stdout remains unchanged as an event projection. +- An opt-in `--trajectory PATH` interface for headless runs. It projects the + internal Event Journal into one complete ATIF-v1.7 JSON document with + user/model steps, tool observations, token metrics, and terminal state, + while preserving text stdout, refusing to overwrite an existing file, and + explicitly marking fields shortened by Event Journal persistence limits. ### Fixed - Declare `httpx` as a direct runtime dependency so clean and containerized diff --git a/docs/dev_notes/en/0.8.x.md b/docs/dev_notes/en/0.8.x.md index d23b580..009a02b 100644 --- a/docs/dev_notes/en/0.8.x.md +++ b/docs/dev_notes/en/0.8.x.md @@ -163,16 +163,50 @@ After receiving it, the journal writer adds the identity, order, and record time These describe the same occurrence rather than two events. A `Native Event` is the runtime fact produced by core; a `Journal Entry` is the persistable record of that fact after it enters the Event Journal, additionally answering which run it belongs to, where it appears in the sequence, and when it was recorded. The ATIF projector consumes Journal Entries ordered by `seq` and folds multiple facts into trajectory steps. -The implementation order is: +Rather than splitting the implementation into seven technically layered and disconnected steps, the work is organized as independently useful and independently testable capabilities. Each capability defines its implementation and acceptance criteria together instead of postponing all validation until the end: -1. **Define runtime facts.** Establish a versioned contract for Native Events and Journal Entries. The first Native Event version covers `run.started`, `user.message`, `model.started/completed`, `tool.started/completed`, and `run.completed/failed`, carrying message and tool-call IDs, reliable `source_timestamp` values, and precise durations. Journal Entries then uniformly add `schema_version`, `run_id`, a strictly increasing `seq`, and UTC `recorded_at`. -2. **Make the agent loop emit facts only.** Emit events around model calls, tool execution, and run finalization. `model.completed` retains the complete response, actual model, stop reason, token and cache usage, provider response ID, and OpenRouter generation ID. Project the existing text output from those facts while preserving current user-visible behavior, leaving one shared data source for a future `stream-json` implementation. -3. **Append to the internal Event Journal.** The journal writer wraps every event in a Journal Entry and appends it incrementally as JSONL under restricted file permissions, preserving the last complete fact even if the process is interrupted. It also records truncation metadata for large output and treats the log explicitly as sensitive. The journal is an internal reconstruction source, is not exposed through `--trajectory`, and does not define ATIF steps. -4. **Complete usage and actual cost.** Record `usage.cost` directly when present. On the current OpenRouter Messages path, preserve `X-Generation-Id` and use bounded retries during run finalization to query the generation's `total_cost`, then append `model.cost_resolved`. Query failure does not change the task result; unknown cost is not written as `0`, and summaries explicitly indicate partial data. -5. **Implement a one-way ATIF projector.** Replay the Event Journal, mapping `user.message` to a user step and folding one model call plus its tool calls and results into an agent step. Map timestamps, tokens, cache, cost, terminal state, and `final_metrics`; only values with reliable sources and complete attribution enter standard fields, with other information under `extra`. At each checkpoint or run end, update the complete ATIF snapshot through a temporary file and atomic rename. -6. **Connect the CLI and Harbor.** Add an independent `--trajectory PATH`, preserve stdout's current text behavior, and reject `-` to avoid competing for stdout. The Harbor adapter only passes the log path, declares and reads ATIF, and then populates steps, tokens, and cost; it no longer converts a native trajectory. `--output-format` and `stream-json` remain for a later independent PR. -7. **Validate in layers.** Start with unit tests for event order, ID associations, failed tools, pending and resolved cost, Journal replay, and the ATIF schema. Then test stdout independence, atomic CLI writes, and interruption recovery. Finally, use Harbor contract tests and one real trial to confirm that the trajectory is collected and that step, token, and cost statistics are populated. +1. **Runtime facts and the Event Journal.** Establish versioned contracts for `Native Event` and `Journal Entry`. The agent loop emits facts only at user, model, tool, and run boundaries, and appends them to an internal Event Journal. Text output is projected from those same facts so existing user-visible behavior remains unchanged. +2. **Public ATIF trajectory.** Implement a one-way Event Journal to ATIF-v1.7 projector and expose it for headless runs through an independent `--trajectory PATH`. `PATH` identifies one complete ATIF JSON snapshot rather than the internal Journal and does not change stdout. The projector maps messages, tools, timestamps, durations, token and cache usage, and terminal state already defined by the Journal. Cost mapping depends on a separate actual-cost contract and backfill mechanism. +3. **Actual-cost backfill.** Record `usage.cost` directly when available. For the current OpenRouter Messages path, use the stored `X-Generation-Id` and bounded retries at run finalization to query the generation's `total_cost`, then append `model.cost_resolved`. Query failures do not change task results; the projector fills resolved costs into the corresponding step and `final_metrics`, and never writes an unknown cost as `0`. +4. **Harbor integration and end-to-end acceptance.** The Harbor adapter only passes a trajectory path, declares and reads the agent-produced ATIF, then populates steps, tokens, and cost. It no longer converts a native trajectory. Harbor contract tests verify collection and statistics first, followed by one real trial of the complete path. -This change completes steps 1–3 first. `event_journal.py` defines schema-version-1 Native Events and Journal Entries and creates an independent JSONL file for every Agent Run. `run.started` records the producer name and the nanoPyCodeAgent version resolved from package metadata; `seq` strictly increases within a run, `recorded_at` is UTC, and directory and file permissions are restricted to `0700` and `0600`, respectively. The writer appends complete lines, while replay ignores the final incomplete record left by an interrupted process and rejects records that move backward. Large strings are truncated only in the persisted copy with their JSON Pointer and original/retained lengths recorded; the core's Native Event remains unchanged. +`--output-format` and `stream-json` are separate run-output capabilities and are outside the trajectory work above. -The agent loop now emits events at user, model, tool, and run boundaries, with an additional `model.output_delta` event preserving the existing streaming display. `model.completed` retains provider-neutral complete content and tool calls, the actual model, stop reason, token and cache usage, provider response ID, and `X-Generation-Id`; tool events retain inputs, results, error state, and precise duration. Model text and tool call/result stdout are now entirely projected from the same facts, and exact-output regression tests for a successful reply, a failed tool, and an interrupted stream show that the existing output is unchanged. Journals live under `~/.nanoPyCodeAgent/journals/` and are sensitive internal reconstruction data containing prompts, repository content, and tool results—not the public `--trajectory` format. Steps 4–7 are not implemented in this change. +#### Runtime facts and the Event Journal + +**Goal:** establish versioned Native Event and Journal Entry contracts so the agent loop produces only runtime facts, then append a replayable internal Event Journal for each Agent Run. Existing stdout text is projected from the same facts, but the Event Journal itself is sensitive internal reconstruction data rather than public run output or a trajectory. + +**Protocol:** the [Event Journal implementation protocol v1](../../dev_docs/en/event-journal-protocol-v1.md) is the single definition of the schema-version-1 wire contract, event semantics, persistence behavior, and compatibility boundaries. These development notes do not repeat its field-level validation details. + +**Validation:** behavioral tests demonstrate that event contracts and ordering are validated, Journals can be appended and replayed, interruption does not damage complete records, sensitive-data permissions are restricted, and event-based projection leaves stdout unchanged: + +```bash +uv run pytest \ + tests/test_event_journal.py \ + tests/test_agent_events.py \ + tests/test_agent.py +``` + +Development acceptance requires these tests to return zero and every mandatory protocol behavior to have a corresponding test. + +#### Public ATIF trajectory + +**Goal:** implement a one-way Event Journal to ATIF-v1.7 projector that exports one headless Agent Run as a complete ATIF JSON document. The CLI gains an independent `--trajectory PATH`; `PATH` selects the output file while stdout keeps its existing text behavior. The internal Event Journal remains private. Actual-cost backfill, Harbor adapter collection, `stream-json`, and multi-run trajectories for interactive sessions are outside this capability. + +**Protocol:** the target format is ATIF-v1.7 as implemented by Harbor 0.21.0: + +- [Harbor's ATIF documentation](https://www.harborframework.com/docs/agents/trajectory-format) +- [ATIF-v1.7 RFC](https://github.com/harbor-framework/harbor/blob/v0.21.0/rfcs/0001-trajectory-format.md) +- [Pydantic reference implementation](https://github.com/harbor-framework/harbor/tree/v0.21.0/src/harbor/models/trajectories) and [trajectory validator](https://github.com/harbor-framework/harbor/blob/v0.21.0/src/harbor/utils/trajectory_validator.py) + +**Validation:** automated tests demonstrate that the projector preserves representative user, model, tool, usage, and terminal facts from the Journal; CLI stdout remains unchanged; and a file is created only when `--trajectory` is supplied. Every generated trajectory must also pass the repository-pinned Harbor 0.21.0 validator: + +```bash +nanoPyCodeAgent -p "read README.md and summarize it" \ + --trajectory /tmp/nanopycodeagent-trajectory.json +uv run --project benchmarks/harbor \ + python -m harbor.utils.trajectory_validator \ + /tmp/nanopycodeagent-trajectory.json +``` + +Development acceptance requires the validator to return zero, stdout to retain its existing text format, and the target to contain complete JSON. Harbor adapter collection and a real trial belong to the later Harbor integration acceptance work. diff --git a/docs/dev_notes/zh-CN/0.8.x.md b/docs/dev_notes/zh-CN/0.8.x.md index a6f3fbb..a203f19 100644 --- a/docs/dev_notes/zh-CN/0.8.x.md +++ b/docs/dev_notes/zh-CN/0.8.x.md @@ -123,7 +123,7 @@ harbor run \ ### 实现 Trajectory -根据调研([agent output 与 trajectory 边界](../../research/zh-CN/agent_output_and_trajectory.md)、[agent 事件到 ATIF 的映射](../../research/zh-CN/agent_events_to_atif_examples.md)、[OpenRouter cost 记账](../../research/zh-CN/openrouter_cost_accounting.md)、[OpenRouter 统一模型协议](../../research/zh-CN/openrouter_unified_protocol.md)),实现路线收敛为:内部保留可重放的 **Event Journal**,对外的 `--trajectory` 只产出 **ATIF-v1.7**。`stream-json` 仍是独立的 run output,只与 trajectory 复用同一组运行事实;它和对应的 `--output-format` CLI 接口不在本轮 trajectory 实现范围内,后续单开 PR。 +根据调研([agent output 与 trajectory 边界](../../research/zh-CN/agent_output_and_trajectory.md)、[agent 事件到 ATIF 的映射](../../research/zh-CN/agent_events_to_atif_examples.md)、[OpenRouter cost 记账](../../research/zh-CN/openrouter_cost_accounting.md)、[OpenRouter 统一模型协议](../../research/zh-CN/openrouter_unified_protocol.md)),实现路线收敛为:内部保留可重放的 **Event Journal**,对外的 `--trajectory` 只产出 **ATIF-v1.7**。`stream-json` 是独立的 run output,只与 trajectory 复用同一组运行事实;它和对应的 `--output-format` CLI 接口属于另一个控制面,不属于 trajectory 实现范围。 先用一次 `read` 工具执行完成来说明 `Native Event` 与 `Journal Entry` 的概念。 @@ -163,16 +163,50 @@ journal writer 接收它以后,补上持久化所需的身份、顺序和记 两者描述的是同一件事,不是两个事件:`Native Event` 是 core 产生的运行事实;`Journal Entry` 是这条事实进入 Event Journal 后的可持久化记录,额外回答“属于哪次 run、排在第几、何时被记录”。ATIF projector 消费的是按 `seq` 排列的 Journal Entry,再把多条事实折叠成 trajectory step。 -整体按以下顺序实现: +实现不按技术层级拆成七个前后割裂的步骤,而是按可独立使用、可独立验收的能力组织。每项能力同时定义实现与验收,不把“验收”作为脱离具体功能的最后一步: -1. **定义运行事实。** 建立 `Native Event` 与 `Journal Entry` 的版本化契约。Native Event 第一版覆盖 `run.started`、`user.message`、`model.started/completed`、`tool.started/completed`、`run.completed/failed`,并携带 message/tool call ID、可信的 `source_timestamp` 和精确 duration;Journal Entry 再统一添加 `schema_version`、`run_id`、严格递增的 `seq` 和 UTC `recorded_at`。 -2. **让 agent loop 只产生事实。** 在模型调用、工具执行和 run 收尾处发出事件;`model.completed` 保留完整回复、实际 model、stop reason、token/cache usage、provider response ID 和 OpenRouter generation ID。现有文本输出改为这些事实的投影,保持当前用户可见行为不变,也为后续 `stream-json` 留出同一数据源。 -3. **追加写入内部 Event Journal。** journal writer 把每个事件包装成 Journal Entry,以受限文件权限和 JSONL 增量落盘,使进程中断后仍保留最后一条完整事实;同时为大输出保留截断元数据,并把日志明确视为敏感文件。它是内部重建来源,不由 `--trajectory` 暴露,也不定义 ATIF step。 -4. **补齐 usage 与真实 cost。** 有 `usage.cost` 时直接记录;当前 OpenRouter Messages 路径则保存 `X-Generation-Id`,在 run 收尾时用有界重试查询 generation 的 `total_cost`,再追加 `model.cost_resolved`。查询失败不改变任务结果,未知 cost 不写成 `0`,汇总时明确标记 partial。 -5. **实现单向 ATIF projector。** 重放 Event Journal,将 `user.message` 映射为 user step,将一次模型调用及其 tool calls/results 折叠成 agent step,并映射 timestamp、token、cache、cost、终态和 `final_metrics`;只有来源可靠、归因完整的值才进入标准字段,其余信息放入 `extra`。每次 checkpoint 或 run 结束时通过临时文件加原子 rename 更新完整 ATIF 快照。 -6. **接通 CLI 与 Harbor。** 增加独立的 `--trajectory PATH`,保持 stdout 当前的文本行为,并拒绝 `-` 与 stdout 争用;Harbor adapter 只负责传入日志路径、声明并读取 ATIF,再回填 steps、tokens 和 cost,不再做 native trajectory 转换。`--output-format` 与 `stream-json` 留给后续独立 PR。 -7. **分层验收。** 先用单元测试覆盖事件顺序、ID 关联、异常工具、pending/resolved cost、Journal 重放和 ATIF schema;再验证 CLI 的 stdout 独立性、原子写入和中断恢复;最后用 Harbor 契约测试与一次真实 trial 确认 trajectory 被采集,步骤与 token/cost 统计能够回填。 +1. **运行事实与 Event Journal。** 建立 `Native Event` 与 `Journal Entry` 的版本化契约,让 agent loop 在 user、model、tool 与 run 边界只产生事实,并把事实追加写入内部 Event Journal。文本输出也从同一组事实投影,保持用户可见行为不变。 +2. **公开 ATIF trajectory。** 实现 Event Journal 到 ATIF-v1.7 的单向 projector,并用独立的 `--trajectory PATH` 在 headless run 中启用它。`PATH` 指定一份完整 ATIF JSON 快照的文件位置,不指向内部 Journal,也不改变 stdout。projector 映射 Journal 中已定义的 message、tool、timestamp、duration、token/cache usage 与 run 终态;cost 映射依赖独立的真实 cost 契约与补账机制。 +3. **真实 cost 补账。** 有 `usage.cost` 时直接记录;当前 OpenRouter Messages 路径则使用已保存的 `X-Generation-Id`,在 run 收尾时用有界重试查询 generation 的 `total_cost`,再追加 `model.cost_resolved`。查询失败不改变任务结果,projector 将已解析 cost 回填到对应 step 和 `final_metrics`,未知 cost 不写成 `0`。 +4. **Harbor 接入与端到端验收。** Harbor adapter 只负责传入 trajectory 路径、声明并读取 agent 生成的 ATIF,再回填 steps、tokens 和 cost,不再做 native trajectory 转换。先用 Harbor 契约测试验证采集与统计,再跑一次真实 trial 确认完整链路。 -本次先完成了第 1~3 步。`event_journal.py` 定义了 schema version 1 的 Native Event 与 Journal Entry,并为每次 Agent Run 生成独立 JSONL;`run.started` 记录 producer name 与从包元数据解析的 nanoPyCodeAgent version,`seq` 在 run 内严格递增,`recorded_at` 使用 UTC,目录与文件权限分别收紧为 `0700` 和 `0600`。writer 以追加方式写入完整行,replay 会忽略进程中断留下的最后一条不完整记录,同时拒绝倒序记录;大字符串只在持久化副本中截断,并保留 JSON Pointer 与原始/保留长度,core 中的 Native Event 不受影响。 +`--output-format` 与 `stream-json` 属于独立的 run output,不在上述 trajectory 能力中。 -agent loop 现在在 user、model、tool 与 run 边界发出事件,另外用 `model.output_delta` 保留原有流式显示。`model.completed` 保存 provider-neutral 的完整 content/tool calls、实际 model、stop reason、token/cache usage、provider response ID 与 `X-Generation-Id`;工具事件保存输入、结果、错误状态和精确 duration。模型文本与工具调用/结果的 stdout 全部改由同一组事件投影,针对成功回复、失败工具和流中断的精确输出回归测试证明现有输出没有变化。Journal 位于 `~/.nanoPyCodeAgent/journals/`,是包含提示词、仓库内容和工具结果的敏感内部重建数据,不是 `--trajectory` 的公开格式;本轮没有实现计划第 4~7 步。 +#### 运行事实与 Event Journal + +**要做什么:**建立版本化的 Native Event 与 Journal Entry 契约,让 agent loop 只产生运行事实,再为每次 Agent Run 追加写入一份可重放的内部 Event Journal。现有 stdout 文本也从同一组事实投影,但 Event Journal 本身是敏感的内部重建数据,不是公开 run output 或 trajectory。 + +**协议依据:**schema version 1 的 wire contract、事件语义、持久化行为与兼容性边界统一由 [Event Journal 实现协议 v1](../../dev_docs/zh-CN/event-journal-protocol-v1.md) 定义。开发笔记不重复字段和校验细节。 + +**如何验证:**行为测试要证明事件契约与顺序可校验、Journal 可追加和重放、中断不破坏已完整的记录、敏感数据权限受限,并且事件化后 stdout 行为不变: + +```bash +uv run pytest \ + tests/test_event_journal.py \ + tests/test_agent_events.py \ + tests/test_agent.py +``` + +开发验收以这些测试返回 0,且协议文档中每项必须行为都有对应测试为准。 + +#### 公开 ATIF trajectory + +**要做什么:**实现从 Event Journal 到 ATIF-v1.7 的单向 projector,把一次 headless Agent Run 输出为一份完整 ATIF JSON。CLI 增加独立的 `--trajectory PATH`:参数出现时启用 projector,`PATH` 指定文件位置,stdout 保持现有文本行为。内部 Event Journal 不对外暴露;真实 cost 补账、Harbor adapter 采集、`stream-json` 与交互会话的多 run trajectory 不属于这项能力。 + +**协议依据:**目标格式以 Harbor 0.21.0 中的 ATIF-v1.7 为准: + +- [Harbor 官方 ATIF 文档](https://www.harborframework.com/docs/agents/trajectory-format); +- [ATIF-v1.7 RFC](https://github.com/harbor-framework/harbor/blob/v0.21.0/rfcs/0001-trajectory-format.md); +- [Pydantic 参考实现](https://github.com/harbor-framework/harbor/tree/v0.21.0/src/harbor/models/trajectories) 与 [trajectory validator](https://github.com/harbor-framework/harbor/blob/v0.21.0/src/harbor/utils/trajectory_validator.py)。 + +**如何验证:**自动化测试要证明 projector 能从代表性 Journal 中保留 user/model/tool、usage 和 run 终态,CLI 的 stdout 不变,且只在传入 `--trajectory` 时生成文件。生成的每份 trajectory 还必须通过仓库已 pin 的 Harbor 0.21.0 validator: + +```bash +nanoPyCodeAgent -p "read README.md and summarize it" \ + --trajectory /tmp/nanopycodeagent-trajectory.json +uv run --project benchmarks/harbor \ + python -m harbor.utils.trajectory_validator \ + /tmp/nanopycodeagent-trajectory.json +``` + +开发验收以 validator 返回 0、stdout 仍为原有文本输出、目标文件为完整 JSON 为准。Harbor adapter 读取与真实 trial 属于后续的 Harbor 接入验收。 diff --git a/src/nanopycodeagent/agent.py b/src/nanopycodeagent/agent.py index 7273473..76c75ed 100644 --- a/src/nanopycodeagent/agent.py +++ b/src/nanopycodeagent/agent.py @@ -24,6 +24,7 @@ import time import uuid from importlib.metadata import PackageNotFoundError, version +from pathlib import Path try: # Importing readline routes input() through a line editor that redraws the @@ -39,6 +40,7 @@ import httpx from anthropic.types import MessageParam, ToolResultBlockParam, ToolUseBlock +from .atif import project_atif, write_atif from .bash_tool import BASH_TOOL, run_bash from .edit_tool import EDIT_TOOL, edit_preview, run_edit from .event_journal import ( @@ -346,6 +348,7 @@ def _run_exchange( *, max_turns: int | None = None, reply_prefix: str = "\nAgent> ", + trajectory_path: Path | None = None, ) -> bool: """Reply to the conversation so far, running tools until the model stops. @@ -402,14 +405,22 @@ def _run_exchange( }, ) raise - emitter.emit( - "run.completed", - { - "outcome": "completed" if finished else "max_turns_exhausted", - "duration_ms": (time.perf_counter_ns() - run_started_ns) / 1_000_000, - "source_timestamp": utc_now(), - }, - ) + else: + emitter.emit( + "run.completed", + { + "outcome": "completed" if finished else "max_turns_exhausted", + "duration_ms": (time.perf_counter_ns() - run_started_ns) + / 1_000_000, + "source_timestamp": utc_now(), + }, + ) + finally: + if trajectory_path is not None: + write_atif( + project_atif(EventJournal.replay(journal.path)), + trajectory_path, + ) return finished @@ -545,7 +556,12 @@ def run() -> int: return 0 -def run_headless(task: str, *, max_turns: int = DEFAULT_MAX_TURNS) -> int: +def run_headless( + task: str, + *, + max_turns: int = DEFAULT_MAX_TURNS, + trajectory_path: Path | None = None, +) -> int: """Work ``task`` to completion without a user, and return the exit code. The exit code answers one question — did the *harness* fail, or did the @@ -578,6 +594,7 @@ def run_headless(task: str, *, max_turns: int = DEFAULT_MAX_TURNS) -> int: HEADLESS_SYSTEM_PROMPT, max_turns=max_turns, reply_prefix="", + trajectory_path=trajectory_path, ) except (anthropic.APIError, httpx.HTTPError) as exc: # Printed verbatim on purpose: a harness classifies a failed run by diff --git a/src/nanopycodeagent/atif.py b/src/nanopycodeagent/atif.py new file mode 100644 index 0000000..869a6eb --- /dev/null +++ b/src/nanopycodeagent/atif.py @@ -0,0 +1,448 @@ +"""Project one Event Journal into an ATIF-v1.7 trajectory.""" + +from __future__ import annotations + +import json +import os +import tempfile +from collections.abc import Sequence +from pathlib import Path + +from .event_journal import JsonObject, JsonValue, JournalEntry, SCHEMA_VERSION + +ATIF_SCHEMA_VERSION = "ATIF-v1.7" + + +class AtifProjectionError(ValueError): + """The Event Journal cannot be represented as a complete ATIF document.""" + + +def _timestamp(entry: JournalEntry) -> tuple[str, str]: + source_timestamp = entry.payload.get("source_timestamp") + if isinstance(source_timestamp, str): + return source_timestamp, "source_timestamp" + return entry.recorded_at, "recorded_at" + + +def _journal_truncation( + entry: JournalEntry, + *path_prefixes: str, +) -> JsonObject | None: + """Return truncation facts relevant to fields projected from ``entry``.""" + if entry.truncation is None: + return None + fields = entry.truncation["fields"] + assert isinstance(fields, list) + if path_prefixes: + fields = [ + field + for field in fields + if isinstance(field, dict) + and isinstance(field.get("path"), str) + and any( + field["path"] == prefix + or str(field["path"]).startswith(f"{prefix}/") + for prefix in path_prefixes + ) + ] + if not fields: + return None + return {"fields": fields} + + +def _add_journal_truncation( + extra: JsonObject, + entry: JournalEntry, + *path_prefixes: str, +) -> None: + truncation = _journal_truncation(entry, *path_prefixes) + if truncation is not None: + extra["journal_truncation"] = truncation + + +def _message(content: JsonValue) -> str | list[JsonObject]: + if isinstance(content, str): + return content + if not isinstance(content, list): + raise AtifProjectionError("ATIF message content must be text or content parts") + + text_parts = [ + {"type": "text", "text": block["text"]} + for block in content + if isinstance(block, dict) + and block.get("type") == "text" + and isinstance(block.get("text"), str) + ] + if not text_parts: + return "" + if len(text_parts) == 1: + text = text_parts[0]["text"] + assert isinstance(text, str) + return text + return text_parts + + +def _metrics(usage: JsonObject | None) -> JsonObject | None: + if usage is None: + return None + input_tokens = usage["input_tokens"] + output_tokens = usage["output_tokens"] + cache_read = usage.get("cache_read_input_tokens", 0) + cache_creation = usage.get("cache_creation_input_tokens", 0) + assert isinstance(input_tokens, int) + assert isinstance(output_tokens, int) + assert isinstance(cache_read, int) + assert isinstance(cache_creation, int) + + metrics: JsonObject = { + "prompt_tokens": input_tokens + cache_read + cache_creation, + "completion_tokens": output_tokens, + "cached_tokens": cache_read, + } + metrics_extra: JsonObject = {} + if "cache_creation_input_tokens" in usage: + metrics_extra["cache_creation_input_tokens"] = cache_creation + provider_usage = { + key: value + for key, value in usage.items() + if key + not in { + "input_tokens", + "output_tokens", + "cache_read_input_tokens", + "cache_creation_input_tokens", + } + } + if provider_usage: + metrics_extra["provider_usage"] = provider_usage + if metrics_extra: + metrics["extra"] = metrics_extra + return metrics + + +def _step_extra( + entry: JournalEntry, + started: JournalEntry | None, +) -> JsonObject: + payload = entry.payload + _, timestamp_source = _timestamp(entry) + extra: JsonObject = { + "model_call_id": payload["model_call_id"], + "message_id": payload["message_id"], + "provider_response_id": payload["provider_response_id"], + "generation_id": payload["generation_id"], + "stop_reason": payload["stop_reason"], + "duration_ms": payload["duration_ms"], + } + content = payload["content"] + assert isinstance(content, list) + content_extensions = [ + block + for block in content + if isinstance(block, dict) and block.get("type") == "extension" + ] + if content_extensions: + extra["content_extensions"] = content_extensions + if started is not None: + started_at, started_source = _timestamp(started) + extra["started_at"] = started_at + extra["started_at_source"] = started_source + extra["timestamp_source"] = timestamp_source + _add_journal_truncation(extra, entry, "/content") + return extra + + +def _tool_calls_and_observation( + entry: JournalEntry, + tool_starts: dict[str, JournalEntry], + tool_completions: dict[str, JournalEntry], +) -> tuple[list[JsonObject], JsonObject | None]: + model_call_id = entry.payload["model_call_id"] + native_tool_calls = entry.payload["tool_calls"] + assert isinstance(model_call_id, str) + assert isinstance(native_tool_calls, list) + tool_calls: list[JsonObject] = [] + results: list[JsonObject] = [] + for tool_call_index, native_tool_call in enumerate(native_tool_calls): + assert isinstance(native_tool_call, dict) + tool_call_id = native_tool_call["tool_call_id"] + assert isinstance(tool_call_id, str) + tool_call: JsonObject = { + "tool_call_id": tool_call_id, + "function_name": native_tool_call["tool_name"], + "arguments": native_tool_call["input"], + } + started = tool_starts.get(tool_call_id) + if started is not None and started.payload.get("model_call_id") in { + None, + model_call_id, + }: + started_at, timestamp_source = _timestamp(started) + tool_call["extra"] = { + "started_at": started_at, + "timestamp_source": timestamp_source, + } + tool_call_extra = tool_call.setdefault("extra", {}) + assert isinstance(tool_call_extra, dict) + _add_journal_truncation( + tool_call_extra, + entry, + f"/tool_calls/{tool_call_index}/input", + ) + if not tool_call_extra: + tool_call.pop("extra") + tool_calls.append(tool_call) + + completed = tool_completions.get(tool_call_id) + if completed is None or completed.payload.get("model_call_id") not in { + None, + model_call_id, + }: + continue + completed_at, timestamp_source = _timestamp(completed) + completed_payload = completed.payload + result_extra: JsonObject = { + "is_error": completed_payload["is_error"], + "duration_ms": completed_payload["duration_ms"], + "timestamp": completed_at, + "timestamp_source": timestamp_source, + } + if "error" in completed_payload: + result_extra["error"] = completed_payload["error"] + _add_journal_truncation(result_extra, completed) + results.append( + { + "source_call_id": tool_call_id, + "content": completed_payload["result"], + "extra": result_extra, + } + ) + observation: JsonObject | None = {"results": results} if results else None + return tool_calls, observation + + +def project_atif(entries: Sequence[JournalEntry]) -> JsonObject: + """Fold a complete headless Event Journal into one ATIF-v1.7 document.""" + if not entries: + raise AtifProjectionError("cannot project an empty Event Journal") + if any(entry.schema_version != SCHEMA_VERSION for entry in entries): + raise AtifProjectionError("unsupported Event Journal schema") + run_ids = {entry.run_id for entry in entries} + if len(run_ids) != 1: + raise AtifProjectionError("Event Journal contains more than one run") + if entries[0].type != "run.started": + raise AtifProjectionError("Event Journal must start with run.started") + + started_run = entries[0] + run_payload = started_run.payload + if run_payload["mode"] != "headless": + raise AtifProjectionError("ATIF projection supports headless runs only") + producer = run_payload["producer"] + assert isinstance(producer, dict) + + steps: list[JsonObject] = [] + model_starts: dict[str, JournalEntry] = {} + model_deltas: dict[str, list[JournalEntry]] = {} + completed_model_calls: set[str] = set() + tool_starts = { + str(entry.payload["tool_call_id"]): entry + for entry in entries + if entry.type == "tool.started" + } + tool_completions = { + str(entry.payload["tool_call_id"]): entry + for entry in entries + if entry.type == "tool.completed" + } + terminal: JournalEntry | None = None + for entry in entries[1:]: + payload = entry.payload + if entry.type == "user.message": + timestamp, timestamp_source = _timestamp(entry) + extra: JsonObject = { + "message_id": payload["message_id"], + "timestamp_source": timestamp_source, + } + _add_journal_truncation(extra, entry, "/content") + steps.append( + { + "step_id": len(steps) + 1, + "timestamp": timestamp, + "source": "user", + "message": _message(payload["content"]), + "extra": extra, + } + ) + elif entry.type == "model.started": + model_call_id = payload["model_call_id"] + assert isinstance(model_call_id, str) + model_starts[model_call_id] = entry + elif entry.type == "model.output_delta": + model_call_id = payload["model_call_id"] + assert isinstance(model_call_id, str) + model_deltas.setdefault(model_call_id, []).append(entry) + elif entry.type == "model.completed": + model_call_id = payload["model_call_id"] + assert isinstance(model_call_id, str) + completed_model_calls.add(model_call_id) + timestamp, _ = _timestamp(entry) + usage = payload["usage"] + assert usage is None or isinstance(usage, dict) + step: JsonObject = { + "step_id": len(steps) + 1, + "timestamp": timestamp, + "source": "agent", + "model_name": payload["model"], + "message": _message(payload["content"]), + "llm_call_count": 1, + "extra": _step_extra(entry, model_starts.get(model_call_id)), + } + metrics = _metrics(usage) + if metrics is not None: + step["metrics"] = metrics + tool_calls, observation = _tool_calls_and_observation( + entry, + tool_starts, + tool_completions, + ) + if tool_calls: + step["tool_calls"] = tool_calls + if observation is not None: + step["observation"] = observation + steps.append(step) + elif entry.type in {"run.completed", "run.failed"}: + terminal = entry + + for model_call_id, started in model_starts.items(): + if model_call_id in completed_model_calls: + continue + deltas = model_deltas.get(model_call_id, []) + timestamp_entry = deltas[-1] if deltas else started + timestamp, timestamp_source = _timestamp(timestamp_entry) + started_at, started_at_source = _timestamp(started) + extra: JsonObject = { + "model_call_id": model_call_id, + "incomplete": True, + "started_at": started_at, + "started_at_source": started_at_source, + "timestamp_source": timestamp_source, + } + truncated_deltas = [ + { + "journal_seq": delta.seq, + "truncation": delta.truncation, + } + for delta in deltas + if delta.truncation is not None + ] + if truncated_deltas: + extra["journal_truncations"] = truncated_deltas + steps.append( + { + "step_id": len(steps) + 1, + "timestamp": timestamp, + "source": "agent", + "model_name": started.payload["model"], + "message": "".join(str(delta.payload["delta"]) for delta in deltas), + "llm_call_count": 1, + "extra": extra, + } + ) + + if not steps: + raise AtifProjectionError("ATIF trajectory requires at least one step") + if terminal is None: + raise AtifProjectionError("Event Journal has no terminal event") + + terminal_timestamp, terminal_timestamp_source = _timestamp(terminal) + terminal_payload = terminal.payload + terminal_data: JsonObject = { + "status": "completed" if terminal.type == "run.completed" else "failed", + "duration_ms": terminal_payload["duration_ms"], + "timestamp": terminal_timestamp, + "timestamp_source": terminal_timestamp_source, + } + if terminal.type == "run.completed": + terminal_data["outcome"] = terminal_payload["outcome"] + else: + terminal_data["error_type"] = terminal_payload["error_type"] + terminal_data["message"] = terminal_payload["message"] + _add_journal_truncation(terminal_data, terminal) + + trajectory: JsonObject = { + "schema_version": ATIF_SCHEMA_VERSION, + "session_id": entries[0].run_id, + "trajectory_id": entries[0].run_id, + "agent": { + "name": producer["name"], + "version": producer["version"], + "model_name": run_payload["model"], + "extra": { + "mode": run_payload["mode"], + "max_turns": run_payload["max_turns"], + }, + }, + "steps": steps, + "final_metrics": {"total_steps": len(steps)}, + "extra": {"terminal": terminal_data}, + } + + step_metrics = [ + step["metrics"] + for step in steps + if isinstance(step.get("metrics"), dict) + ] + llm_steps = [step for step in steps if step.get("llm_call_count") == 1] + final_metrics = trajectory["final_metrics"] + assert isinstance(final_metrics, dict) + if llm_steps and len(step_metrics) == len(llm_steps): + final_metrics["total_prompt_tokens"] = sum( + int(metrics["prompt_tokens"]) for metrics in step_metrics + ) + final_metrics["total_completion_tokens"] = sum( + int(metrics["completion_tokens"]) for metrics in step_metrics + ) + final_metrics["total_cached_tokens"] = sum( + int(metrics["cached_tokens"]) for metrics in step_metrics + ) + elif llm_steps: + final_metrics["extra"] = {"usage_complete": False} + return trajectory + + +def write_atif(trajectory: JsonObject, path: Path) -> None: + """Atomically publish one owner-only ATIF JSON file without overwriting.""" + encoded = ( + json.dumps( + trajectory, + ensure_ascii=False, + allow_nan=False, + indent=2, + ) + + "\n" + ).encode("utf-8") + descriptor = -1 + temporary_path: Path | None = None + try: + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", + suffix=".tmp", + dir=path.parent, + ) + temporary_path = Path(temporary_name) + os.fchmod(descriptor, 0o600) + remaining = memoryview(encoded) + while remaining: + written = os.write(descriptor, remaining) + if written == 0: + raise OSError("could not write ATIF trajectory") + remaining = remaining[written:] + os.fsync(descriptor) + os.close(descriptor) + descriptor = -1 + os.link(temporary_path, path) + finally: + if descriptor >= 0: + os.close(descriptor) + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) diff --git a/src/nanopycodeagent/cli.py b/src/nanopycodeagent/cli.py index 1cc7582..c895275 100644 --- a/src/nanopycodeagent/cli.py +++ b/src/nanopycodeagent/cli.py @@ -54,6 +54,12 @@ def _build_parser() -> argparse.ArgumentParser: f"(default: {DEFAULT_MAX_TURNS})" ), ) + parser.add_argument( + "--trajectory", + type=Path, + metavar="PATH", + help="write this headless run as an ATIF-v1.7 JSON file", + ) parser.add_argument( "--version", action="version", @@ -94,6 +100,22 @@ def _read_task(args: argparse.Namespace, parser: argparse.ArgumentParser) -> str return task +def _trajectory_path( + value: Path | None, + parser: argparse.ArgumentParser, +) -> Path | None: + if value is None: + return None + if value == Path("-"): + parser.error("--trajectory PATH must be a file, not stdout") + path = value.expanduser() + if path.exists() or path.is_symlink(): + parser.error(f"trajectory path already exists: {path}") + if not path.parent.is_dir(): + parser.error(f"trajectory parent directory does not exist: {path.parent}") + return path + + def main(argv: list[str] | None = None) -> int: """Parse the command line and run, returning the process exit code.""" parser = _build_parser() @@ -103,5 +125,12 @@ def main(argv: list[str] | None = None) -> int: task = _read_task(args, parser) if task is None: + if args.trajectory is not None: + parser.error("--trajectory requires a headless task") return run() - return run_headless(task, max_turns=args.max_turns) + trajectory_path = _trajectory_path(args.trajectory, parser) + return run_headless( + task, + max_turns=args.max_turns, + trajectory_path=trajectory_path, + ) diff --git a/tests/test_atif.py b/tests/test_atif.py new file mode 100644 index 0000000..83d7abb --- /dev/null +++ b/tests/test_atif.py @@ -0,0 +1,500 @@ +"""ATIF-v1.7 projection tests at the Event Journal boundary.""" + +import json +import stat +from pathlib import Path + +import pytest + +from nanopycodeagent.atif import project_atif, write_atif +from nanopycodeagent.event_journal import EventJournal, JournalEntry, NativeEvent + + +TIMESTAMPS = [ + "2026-08-26T08:00:00.001Z", + "2026-08-26T08:00:00.002Z", + "2026-08-26T08:00:00.003Z", + "2026-08-26T08:00:00.004Z", + "2026-08-26T08:00:00.005Z", +] + + +def _journal_entries(tmp_path: Path) -> list[JournalEntry]: + timestamps = iter(TIMESTAMPS) + with EventJournal.create( + "run-atif-1", + directory=tmp_path, + clock=lambda: next(timestamps), + ) as journal: + journal.append( + NativeEvent( + "run.started", + { + "mode": "headless", + "model": "requested-model", + "max_turns": 50, + "producer": {"name": "nanoPyCodeAgent", "version": "0.8.0"}, + "source_timestamp": "2026-08-26T08:00:00.000Z", + }, + ) + ) + journal.append( + NativeEvent( + "user.message", + { + "message_id": "user-1", + "content": "fix it", + "source_timestamp": "2026-08-26T08:00:00.010Z", + }, + ) + ) + journal.append( + NativeEvent( + "model.started", + { + "model_call_id": "model-1", + "model": "requested-model", + "source_timestamp": "2026-08-26T08:00:00.020Z", + }, + ) + ) + journal.append( + NativeEvent( + "model.completed", + { + "model_call_id": "model-1", + "message_id": "msg-1", + "content": [ + {"type": "text", "text": "done"}, + { + "type": "extension", + "namespace": "anthropic", + "source_type": "thinking", + "value": {"signature": "opaque"}, + }, + ], + "tool_calls": [], + "model": "actual-model", + "stop_reason": "end_turn", + "usage": { + "input_tokens": 12, + "output_tokens": 3, + "cache_read_input_tokens": 5, + "cache_creation_input_tokens": 2, + }, + "provider_response_id": "provider-1", + "generation_id": "generation-1", + "duration_ms": 20, + "source_timestamp": "2026-08-26T08:00:00.030Z", + }, + ) + ) + journal.append( + NativeEvent( + "run.completed", + { + "outcome": "completed", + "duration_ms": 40, + "source_timestamp": "2026-08-26T08:00:00.040Z", + }, + ) + ) + return EventJournal.replay(tmp_path / "run-atif-1.jsonl") + + +def test_completed_journal_projects_atif_user_model_usage_and_terminal_state( + tmp_path, +): + assert project_atif(_journal_entries(tmp_path)) == { + "schema_version": "ATIF-v1.7", + "session_id": "run-atif-1", + "trajectory_id": "run-atif-1", + "agent": { + "name": "nanoPyCodeAgent", + "version": "0.8.0", + "model_name": "requested-model", + "extra": {"mode": "headless", "max_turns": 50}, + }, + "steps": [ + { + "step_id": 1, + "timestamp": "2026-08-26T08:00:00.010Z", + "source": "user", + "message": "fix it", + "extra": { + "message_id": "user-1", + "timestamp_source": "source_timestamp", + }, + }, + { + "step_id": 2, + "timestamp": "2026-08-26T08:00:00.030Z", + "source": "agent", + "model_name": "actual-model", + "message": "done", + "metrics": { + "prompt_tokens": 19, + "completion_tokens": 3, + "cached_tokens": 5, + "extra": {"cache_creation_input_tokens": 2}, + }, + "llm_call_count": 1, + "extra": { + "model_call_id": "model-1", + "message_id": "msg-1", + "provider_response_id": "provider-1", + "generation_id": "generation-1", + "stop_reason": "end_turn", + "duration_ms": 20, + "content_extensions": [ + { + "type": "extension", + "namespace": "anthropic", + "source_type": "thinking", + "value": {"signature": "opaque"}, + } + ], + "started_at": "2026-08-26T08:00:00.020Z", + "started_at_source": "source_timestamp", + "timestamp_source": "source_timestamp", + }, + }, + ], + "final_metrics": { + "total_prompt_tokens": 19, + "total_completion_tokens": 3, + "total_cached_tokens": 5, + "total_steps": 2, + }, + "extra": { + "terminal": { + "status": "completed", + "outcome": "completed", + "duration_ms": 40, + "timestamp": "2026-08-26T08:00:00.040Z", + "timestamp_source": "source_timestamp", + } + }, + } + + +def test_tool_lifecycle_is_folded_into_the_originating_agent_step(tmp_path): + recorded_at = iter( + [f"2026-08-26T09:00:00.00{index}Z" for index in range(1, 8)] + ) + with EventJournal.create( + "run-atif-tool", + directory=tmp_path, + clock=lambda: next(recorded_at), + ) as journal: + events = [ + NativeEvent( + "run.started", + { + "mode": "headless", + "model": "model-a", + "max_turns": 5, + "producer": {"name": "nanoPyCodeAgent", "version": "0.8.0"}, + "source_timestamp": "2026-08-26T09:00:00.000Z", + }, + ), + NativeEvent( + "user.message", + { + "message_id": "user-tool", + "content": "read it", + "source_timestamp": "2026-08-26T09:00:00.010Z", + }, + ), + NativeEvent( + "model.started", + { + "model_call_id": "model-tool", + "model": "model-a", + "source_timestamp": "2026-08-26T09:00:00.020Z", + }, + ), + NativeEvent( + "model.completed", + { + "model_call_id": "model-tool", + "message_id": "msg-tool", + "content": [ + {"type": "text", "text": "checking"}, + { + "type": "tool_call", + "tool_call_id": "call-read", + "tool_name": "read", + "input": {"path": "README.md"}, + }, + ], + "tool_calls": [ + { + "type": "tool_call", + "tool_call_id": "call-read", + "tool_name": "read", + "input": {"path": "README.md"}, + } + ], + "model": "model-a", + "stop_reason": "tool_use", + "usage": { + "input_tokens": 10, + "output_tokens": 2, + "service_tier": "standard", + }, + "provider_response_id": "msg-tool", + "generation_id": None, + "duration_ms": 15, + "source_timestamp": "2026-08-26T09:00:00.030Z", + }, + ), + NativeEvent( + "tool.started", + { + "model_call_id": "model-tool", + "tool_call_id": "call-read", + "tool_name": "read", + "input": {"path": "README.md"}, + "source_timestamp": None, + }, + ), + NativeEvent( + "tool.completed", + { + "model_call_id": "model-tool", + "tool_call_id": "call-read", + "tool_name": "read", + "result": "contents", + "is_error": False, + "duration_ms": 4, + "source_timestamp": None, + }, + ), + NativeEvent( + "run.completed", + { + "outcome": "completed", + "duration_ms": 30, + "source_timestamp": "2026-08-26T09:00:00.060Z", + }, + ), + ] + for event in events: + journal.append(event) + + entries = EventJournal.replay(tmp_path / "run-atif-tool.jsonl") + agent_step = project_atif(entries)["steps"][1] + + assert agent_step == { + "step_id": 2, + "timestamp": "2026-08-26T09:00:00.030Z", + "source": "agent", + "model_name": "model-a", + "message": "checking", + "tool_calls": [ + { + "tool_call_id": "call-read", + "function_name": "read", + "arguments": {"path": "README.md"}, + "extra": { + "started_at": "2026-08-26T09:00:00.005Z", + "timestamp_source": "recorded_at", + }, + } + ], + "observation": { + "results": [ + { + "source_call_id": "call-read", + "content": "contents", + "extra": { + "is_error": False, + "duration_ms": 4, + "timestamp": "2026-08-26T09:00:00.006Z", + "timestamp_source": "recorded_at", + }, + } + ] + }, + "metrics": { + "prompt_tokens": 10, + "completion_tokens": 2, + "cached_tokens": 0, + "extra": {"provider_usage": {"service_tier": "standard"}}, + }, + "llm_call_count": 1, + "extra": { + "model_call_id": "model-tool", + "message_id": "msg-tool", + "provider_response_id": "msg-tool", + "generation_id": None, + "stop_reason": "tool_use", + "duration_ms": 15, + "started_at": "2026-08-26T09:00:00.020Z", + "started_at_source": "source_timestamp", + "timestamp_source": "source_timestamp", + }, + } + + +def test_projection_preserves_journal_truncation_metadata(tmp_path): + recorded_at = iter( + [f"2026-08-26T09:30:00.00{index}Z" for index in range(1, 8)] + ) + with EventJournal.create( + "run-atif-truncated", + directory=tmp_path, + clock=lambda: next(recorded_at), + max_string_chars=4, + ) as journal: + events = [ + NativeEvent( + "run.started", + { + "mode": "headless", + "model": "model-a", + "max_turns": 5, + "producer": {"name": "nanoPyCodeAgent", "version": "0.8.0"}, + "source_timestamp": "2026-08-26T09:30:00.000Z", + }, + ), + NativeEvent( + "user.message", + { + "message_id": "user-truncated", + "content": "abcdefghij", + "source_timestamp": "2026-08-26T09:30:00.010Z", + }, + ), + NativeEvent( + "model.started", + { + "model_call_id": "model-truncated", + "model": "model-a", + "source_timestamp": "2026-08-26T09:30:00.020Z", + }, + ), + NativeEvent( + "model.completed", + { + "model_call_id": "model-truncated", + "message_id": "message-truncated", + "content": [ + {"type": "text", "text": "klmnopqrst"}, + { + "type": "tool_call", + "tool_call_id": "tool-truncated", + "tool_name": "read", + "input": {"path": "uvwxyzabcd"}, + }, + ], + "tool_calls": [ + { + "type": "tool_call", + "tool_call_id": "tool-truncated", + "tool_name": "read", + "input": {"path": "uvwxyzabcd"}, + } + ], + "model": "model-a", + "stop_reason": "tool_use", + "usage": None, + "provider_response_id": "provider-truncated", + "generation_id": None, + "duration_ms": 10, + "source_timestamp": "2026-08-26T09:30:00.030Z", + }, + ), + NativeEvent( + "tool.started", + { + "model_call_id": "model-truncated", + "tool_call_id": "tool-truncated", + "tool_name": "read", + "input": {"path": "uvwxyzabcd"}, + "source_timestamp": "2026-08-26T09:30:00.040Z", + }, + ), + NativeEvent( + "tool.completed", + { + "model_call_id": "model-truncated", + "tool_call_id": "tool-truncated", + "tool_name": "read", + "result": "0123456789", + "is_error": False, + "duration_ms": 5, + "source_timestamp": "2026-08-26T09:30:00.050Z", + }, + ), + NativeEvent( + "run.completed", + { + "outcome": "completed", + "duration_ms": 20, + "source_timestamp": "2026-08-26T09:30:00.060Z", + }, + ), + ] + for event in events: + journal.append(event) + + trajectory = project_atif( + EventJournal.replay(tmp_path / "run-atif-truncated.jsonl") + ) + user_step, agent_step = trajectory["steps"] + assert user_step["message"] == "abcd" + assert user_step["extra"]["journal_truncation"] == { + "fields": [ + {"path": "/content", "original_chars": 10, "retained_chars": 4} + ] + } + assert agent_step["message"] == "klmn" + assert agent_step["extra"]["journal_truncation"] == { + "fields": [ + { + "path": "/content/0/text", + "original_chars": 10, + "retained_chars": 4, + }, + { + "path": "/content/1/input/path", + "original_chars": 10, + "retained_chars": 4, + }, + ] + } + tool_call = agent_step["tool_calls"][0] + assert tool_call["arguments"] == {"path": "uvwx"} + assert tool_call["extra"]["journal_truncation"] == { + "fields": [ + { + "path": "/tool_calls/0/input/path", + "original_chars": 10, + "retained_chars": 4, + } + ] + } + result = agent_step["observation"]["results"][0] + assert result["content"] == "0123" + assert result["extra"]["journal_truncation"] == { + "fields": [ + {"path": "/result", "original_chars": 10, "retained_chars": 4} + ] + } + + +def test_atif_file_is_owner_only_complete_json_and_never_overwrites(tmp_path): + trajectory_path = tmp_path / "trajectory.json" + expected = project_atif(_journal_entries(tmp_path)) + + write_atif(expected, trajectory_path) + + assert json.loads(trajectory_path.read_text(encoding="utf-8")) == expected + assert stat.S_IMODE(trajectory_path.stat().st_mode) == 0o600 + + trajectory_path.write_text("keep me", encoding="utf-8") + with pytest.raises(FileExistsError): + write_atif(expected, trajectory_path) + assert trajectory_path.read_text(encoding="utf-8") == "keep me" diff --git a/tests/test_cli.py b/tests/test_cli.py index 7e7734a..016ca4e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,6 +7,8 @@ """ import io +import json +from types import SimpleNamespace import anthropic import httpx @@ -91,6 +93,79 @@ def test_banner_stays_off_stdout_in_a_headless_run(monkeypatch, capsys): assert "nanoPyCodeAgent v" in captured.err # the banner still lands in logs +def test_trajectory_path_writes_atif_without_changing_headless_stdout( + monkeypatch, tmp_path, capsys +): + messages = FakeMessages([[text_block("done")]]) + patch_client(monkeypatch, FakeClient(messages)) + trajectory_path = tmp_path / "trajectory.json" + + assert cli.main( + ["-p", "say hi", "--trajectory", str(trajectory_path)] + ) == 0 + + assert capsys.readouterr().out == "done\n" + trajectory = json.loads(trajectory_path.read_text(encoding="utf-8")) + assert trajectory["schema_version"] == "ATIF-v1.7" + assert trajectory["steps"][0]["message"] == "say hi" + assert trajectory["steps"][1]["message"] == "done" + assert trajectory["extra"]["terminal"]["outcome"] == "completed" + + +def test_headless_run_without_trajectory_does_not_create_public_json( + monkeypatch, tmp_path, capsys +): + monkeypatch.chdir(tmp_path) + messages = FakeMessages([[text_block("done")]]) + patch_client(monkeypatch, FakeClient(messages)) + + assert cli.main(["-p", "say hi"]) == 0 + + assert capsys.readouterr().out == "done\n" + assert list(tmp_path.glob("*.json")) == [] + + +def test_partial_model_usage_is_not_reported_as_complete_trajectory_totals( + monkeypatch, tmp_path, capsys +): + first_usage = SimpleNamespace(input_tokens=10, output_tokens=2) + first = FakeStream( + [tool_use_block("tu_usage", "echo one")], + stop_reason="tool_use", + usage=first_usage, + ) + second = FakeStream([text_block("done")], usage=None) + patch_client(monkeypatch, FakeClient(FakeMessages([first, second]))) + trajectory_path = tmp_path / "trajectory.json" + + assert cli.main( + ["-p", "say hi", "--trajectory", str(trajectory_path)] + ) == 0 + + trajectory = json.loads(trajectory_path.read_text(encoding="utf-8")) + assert trajectory["final_metrics"] == { + "total_steps": 3, + "extra": {"usage_complete": False}, + } + + +def test_existing_trajectory_is_rejected_before_the_agent_run( + monkeypatch, tmp_path, capsys +): + messages = FakeMessages([[text_block("should not run")]]) + patch_client(monkeypatch, FakeClient(messages)) + trajectory_path = tmp_path / "trajectory.json" + trajectory_path.write_text("keep me", encoding="utf-8") + + with pytest.raises(SystemExit) as excinfo: + cli.main(["-p", "say hi", "--trajectory", str(trajectory_path)]) + + assert excinfo.value.code == cli.EXIT_USAGE + assert "trajectory path already exists" in capsys.readouterr().err + assert trajectory_path.read_text(encoding="utf-8") == "keep me" + assert messages.calls == [] + + def test_no_task_on_a_terminal_starts_an_interactive_session(monkeypatch, capsys): monkeypatch.setattr(cli.sys, "stdin", TtyStdin()) messages = FakeMessages([[text_block("hello there")]]) @@ -183,6 +258,52 @@ def _gen(): assert "API error: peer disconnected" in captured.err +def test_failed_headless_run_writes_partial_atif_trajectory( + monkeypatch, tmp_path, capsys +): + class DisconnectingStream(FakeStream): + @property + def text_stream(self): + def _gen(): + yield "partial reply" + raise httpx.ReadError( + "peer disconnected", + request=httpx.Request( + "POST", "https://api.anthropic.com/v1/messages" + ), + ) + + return _gen() + + messages = FakeMessages([DisconnectingStream([])]) + patch_client(monkeypatch, FakeClient(messages)) + trajectory_path = tmp_path / "failed-trajectory.json" + + assert cli.main( + ["-p", "say hi", "--trajectory", str(trajectory_path)] + ) == 1 + + captured = capsys.readouterr() + assert captured.out == "partial reply" + assert "API error: peer disconnected" in captured.err + trajectory = json.loads(trajectory_path.read_text(encoding="utf-8")) + assert trajectory["steps"][1]["message"] == "partial reply" + assert trajectory["steps"][1]["extra"]["incomplete"] is True + terminal = trajectory["extra"]["terminal"] + terminal_summary = { + key: terminal[key] + for key in ("status", "error_type", "message", "timestamp_source") + } + assert terminal_summary == { + "status": "failed", + "error_type": "ReadError", + "message": "peer disconnected", + "timestamp_source": "source_timestamp", + } + assert terminal["duration_ms"] >= 0 + assert terminal["timestamp"].endswith("Z") + + def test_empty_task_is_a_usage_error(monkeypatch): patch_client(monkeypatch, FakeClient(FakeMessages([])))