From cf7226ad9ec46c765d3565394c88dfe1e495c12b Mon Sep 17 00:00:00 2001 From: minixalpha Date: Wed, 26 Aug 2026 22:12:42 +0800 Subject: [PATCH 1/5] docs: reframe trajectory implementation plan --- docs/dev_notes/zh-CN/0.8.x.md | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/docs/dev_notes/zh-CN/0.8.x.md b/docs/dev_notes/zh-CN/0.8.x.md index a6f3fbb..350600c 100644 --- a/docs/dev_notes/zh-CN/0.8.x.md +++ b/docs/dev_notes/zh-CN/0.8.x.md @@ -163,16 +163,28 @@ 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. **运行事实基础(已完成)。** 建立 `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 先完整映射已有的 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 交付中,后续单开 PR。 -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 步。 +第 1 轮已经完成。`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 不受影响。 + +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` 的公开格式。 + +#### 本轮:公开 ATIF trajectory + +本轮以“一次 headless run 能够在不改变 stdout 的前提下,按用户指定路径产生一份可验证的 ATIF-v1.7 trajectory”为完成标准。具体包含: + +1. **ATIF-v1.7 projector。** 重放一次 run 的 Journal Entry,将 `user.message` 映射为 user step,将每次 `model.completed` 及通过 `model_call_id`/`tool_call_id` 关联的 tool calls/results 折叠成 agent step。根级使用 `schema_version: "ATIF-v1.7"`,把 Journal Entry 的 `run_id` 映射为 `session_id`,并把 `run.started.producer.name/version` 映射为必填的 `agent.name/version`,把 `run.started.model` 映射为可选的 `agent.model_name`;step ID 从 1 严格连续递增。 +2. **字段归因。** step 映射 message、model 和 tool arguments/result;tool error、stop reason 和 duration 没有对应的 ATIF 标准字段,分别放入 observation result 或 step `extra`。ATIF timestamp 优先使用可信 `source_timestamp`,否则回退到 Journal Entry 的 `recorded_at`,并在 `extra.timestamp_source` 中记录来源。`prompt_tokens` 包含非缓存、cache creation 和 cache read tokens,`cached_tokens` 只记录 cache hits。只有所有模型调用的对应 token 值都完整且可归因时,`final_metrics` 才写入对应 total;否则省略受影响的 total,并在 `final_metrics.extra.usage_status` 中标记 `partial`,不把部分和冒充完整总量。截断信息、run 终态与其他非标准运行信息进入各级 `extra`。 +3. **本轮的 cost 边界。** 现有 Event Journal 还没有定义 provider-neutral 的 amount、currency、source 与 resolved/partial 契约,因此本轮不把 provider payload 中任意名为 `cost` 的字段直接投影成真实美元费用。所有 step 省略 `cost_usd`,`final_metrics` 省略 `total_cost_usd` 并在其 `extra.cost_status` 中记录 `unavailable`,不用 `0` 表示未知。本轮不请求 OpenRouter Generation API,也不新增 `model.cost_resolved`。 +4. **`--trajectory PATH` CLI。** 参数出现即为本次 run 启用 projector,参数值是 ATIF JSON 文件的精确位置;不传参数时不生成公开 trajectory。首版只支持 `-p`、`--prompt-file` 或 stdin 启动的 headless run;没有任务的交互会话会在一个进程中产生多个 run,在定义多 run 命名契约之前,与 `--trajectory` 组合时按 CLI usage error 拒绝。 +5. **路径与快照安全。** `PATH` 表示文件而不是目录,拒绝 `-`、已存在的目标和不存在的父目录,并在开始模型调用前失败,避免为无法保存的运行付费。目标在 Unix 上使用 `0600` 权限。只有已包含 `user.message` 的 Journal 前缀才能生成 ATIF 要求的至少一个 step;首份快照在 `user.message` 后写入。不含 tool calls 的 `model.completed` 可直接形成下一份快照;含 tool calls 时,要等该模型调用的所有工具都产生 `tool.completed` 后再 checkpoint,不对外暴露缺少 observation 的半完成 agent step。`run.completed` 或 `run.failed` 之后总是再写一次最终快照;失败 run 中无法完成的 tool call 作为已知终态记录在 `extra`。每次都用同目录临时文件加原子 rename 更新一份 schema-valid 快照,不暴露半写 JSON。 +6. **本轮验收。** projector 单元测试覆盖纯文本回复、多轮/多工具调用、失败工具、max-turns 终态、run failure、ID 关联、timestamp 回退、截断元数据、token/cache 映射与未知 cost。CLI 测试覆盖 stdout 完全不变、无参数时不生成公开文件、路径误用、`0600` 权限、原子更新和失败 run 仍保留最后一份完整快照。根项目不增加 Harbor 运行时依赖;`benchmarks/harbor/` 可用已 pin 的 Harbor 0.21.0 ATIF 模型做 schema 契约验证,但本轮不修改 adapter 的采集逻辑,也不跑真实 trial。 + +本轮明确不包含 OpenRouter generation cost 补账、Harbor adapter 接入、`--output-format`、`stream-json`、session/resume 与交互会话的多 run trajectory 命名,避免再次把不同控制面混入同一个交付。 From a7a42c3bc95f0243a50837f37f1080be637421d7 Mon Sep 17 00:00:00 2001 From: minixalpha Date: Wed, 26 Aug 2026 22:31:01 +0800 Subject: [PATCH 2/5] docs: clarify trajectory capability boundaries --- docs/dev_notes/zh-CN/0.8.x.md | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/docs/dev_notes/zh-CN/0.8.x.md b/docs/dev_notes/zh-CN/0.8.x.md index 350600c..2613a5e 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,28 +163,30 @@ journal writer 接收它以后,补上持久化所需的身份、顺序和记 两者描述的是同一件事,不是两个事件:`Native Event` 是 core 产生的运行事实;`Journal Entry` 是这条事实进入 Event Journal 后的可持久化记录,额外回答“属于哪次 run、排在第几、何时被记录”。ATIF projector 消费的是按 `seq` 排列的 Journal Entry,再把多条事实折叠成 trajectory step。 -实现不再按技术层级拆成七个前后割裂的步骤,而是按可独立使用、可独立验收的纵向交付组织。测试随所属功能在同一轮完成,不再把“验收”放到所有实现之后: +实现不按技术层级拆成七个前后割裂的步骤,而是按可独立使用、可独立验收的能力组织。每项能力同时定义实现与验收,不把“验收”作为脱离具体功能的最后一步: -1. **运行事实基础(已完成)。** 建立 `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 先完整映射已有的 message、tool、timestamp、duration、token/cache usage 与 run 终态;cost 字段统一留给下一轮的真实 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 确认完整链路。 -`--output-format` 与 `stream-json` 仍属于独立的 run output,不在上述 trajectory 交付中,后续单开 PR。 +`--output-format` 与 `stream-json` 属于独立的 run output,不在上述 trajectory 能力中。 -第 1 轮已经完成。`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 不受影响。 +#### 运行事实与 Event Journal + +schema version 1 的完整 wire contract、事件语义、持久化行为与兼容性边界见 [Event Journal 实现协议 v1](../../dev_docs/zh-CN/event-journal-protocol-v1.md)。`event_journal.py` 定义 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 不受影响。 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` 的公开格式。 -#### 本轮:公开 ATIF trajectory +#### 公开 ATIF trajectory -本轮以“一次 headless run 能够在不改变 stdout 的前提下,按用户指定路径产生一份可验证的 ATIF-v1.7 trajectory”为完成标准。具体包含: +公开 ATIF trajectory 的完成标准是:一次 headless run 能够在不改变 stdout 的前提下,按用户指定路径产生一份可验证的 ATIF-v1.7 trajectory。具体包含: 1. **ATIF-v1.7 projector。** 重放一次 run 的 Journal Entry,将 `user.message` 映射为 user step,将每次 `model.completed` 及通过 `model_call_id`/`tool_call_id` 关联的 tool calls/results 折叠成 agent step。根级使用 `schema_version: "ATIF-v1.7"`,把 Journal Entry 的 `run_id` 映射为 `session_id`,并把 `run.started.producer.name/version` 映射为必填的 `agent.name/version`,把 `run.started.model` 映射为可选的 `agent.model_name`;step ID 从 1 严格连续递增。 2. **字段归因。** step 映射 message、model 和 tool arguments/result;tool error、stop reason 和 duration 没有对应的 ATIF 标准字段,分别放入 observation result 或 step `extra`。ATIF timestamp 优先使用可信 `source_timestamp`,否则回退到 Journal Entry 的 `recorded_at`,并在 `extra.timestamp_source` 中记录来源。`prompt_tokens` 包含非缓存、cache creation 和 cache read tokens,`cached_tokens` 只记录 cache hits。只有所有模型调用的对应 token 值都完整且可归因时,`final_metrics` 才写入对应 total;否则省略受影响的 total,并在 `final_metrics.extra.usage_status` 中标记 `partial`,不把部分和冒充完整总量。截断信息、run 终态与其他非标准运行信息进入各级 `extra`。 -3. **本轮的 cost 边界。** 现有 Event Journal 还没有定义 provider-neutral 的 amount、currency、source 与 resolved/partial 契约,因此本轮不把 provider payload 中任意名为 `cost` 的字段直接投影成真实美元费用。所有 step 省略 `cost_usd`,`final_metrics` 省略 `total_cost_usd` 并在其 `extra.cost_status` 中记录 `unavailable`,不用 `0` 表示未知。本轮不请求 OpenRouter Generation API,也不新增 `model.cost_resolved`。 -4. **`--trajectory PATH` CLI。** 参数出现即为本次 run 启用 projector,参数值是 ATIF JSON 文件的精确位置;不传参数时不生成公开 trajectory。首版只支持 `-p`、`--prompt-file` 或 stdin 启动的 headless run;没有任务的交互会话会在一个进程中产生多个 run,在定义多 run 命名契约之前,与 `--trajectory` 组合时按 CLI usage error 拒绝。 +3. **cost 边界。** 公开 ATIF trajectory 只消费 provider-neutral 的 amount、currency、source 与 resolved/partial 事实,不把 provider payload 中任意名为 `cost` 的字段直接投影成真实美元费用。在真实 cost 契约提供这些事实之前,所有 step 省略 `cost_usd`,`final_metrics` 省略 `total_cost_usd` 并在其 `extra.cost_status` 中记录 `unavailable`,不用 `0` 表示未知。OpenRouter Generation API 查询与 `model.cost_resolved` 属于真实 cost 补账能力。 +4. **`--trajectory PATH` CLI。** 参数出现即为本次 run 启用 projector,参数值是 ATIF JSON 文件的精确位置;不传参数时不生成公开 trajectory。`--trajectory` 只支持由 `-p`、`--prompt-file` 或 stdin 启动的 headless run;没有任务的交互会话会在一个进程中产生多个 run,在定义多 run 命名契约之前,与 `--trajectory` 组合时按 CLI usage error 拒绝。 5. **路径与快照安全。** `PATH` 表示文件而不是目录,拒绝 `-`、已存在的目标和不存在的父目录,并在开始模型调用前失败,避免为无法保存的运行付费。目标在 Unix 上使用 `0600` 权限。只有已包含 `user.message` 的 Journal 前缀才能生成 ATIF 要求的至少一个 step;首份快照在 `user.message` 后写入。不含 tool calls 的 `model.completed` 可直接形成下一份快照;含 tool calls 时,要等该模型调用的所有工具都产生 `tool.completed` 后再 checkpoint,不对外暴露缺少 observation 的半完成 agent step。`run.completed` 或 `run.failed` 之后总是再写一次最终快照;失败 run 中无法完成的 tool call 作为已知终态记录在 `extra`。每次都用同目录临时文件加原子 rename 更新一份 schema-valid 快照,不暴露半写 JSON。 -6. **本轮验收。** projector 单元测试覆盖纯文本回复、多轮/多工具调用、失败工具、max-turns 终态、run failure、ID 关联、timestamp 回退、截断元数据、token/cache 映射与未知 cost。CLI 测试覆盖 stdout 完全不变、无参数时不生成公开文件、路径误用、`0600` 权限、原子更新和失败 run 仍保留最后一份完整快照。根项目不增加 Harbor 运行时依赖;`benchmarks/harbor/` 可用已 pin 的 Harbor 0.21.0 ATIF 模型做 schema 契约验证,但本轮不修改 adapter 的采集逻辑,也不跑真实 trial。 +6. **验收。** projector 单元测试覆盖纯文本回复、多轮/多工具调用、失败工具、max-turns 终态、run failure、ID 关联、timestamp 回退、截断元数据、token/cache 映射与未知 cost。CLI 测试覆盖 stdout 完全不变、无参数时不生成公开文件、路径误用、`0600` 权限、原子更新和失败 run 仍保留最后一份完整快照。根项目不增加 Harbor 运行时依赖;`benchmarks/harbor/` 可用已 pin 的 Harbor 0.21.0 ATIF 模型做 schema 契约验证,但不修改 adapter 的采集逻辑,也不跑真实 trial。 -本轮明确不包含 OpenRouter generation cost 补账、Harbor adapter 接入、`--output-format`、`stream-json`、session/resume 与交互会话的多 run trajectory 命名,避免再次把不同控制面混入同一个交付。 +**非目标:**公开 ATIF trajectory 不包含 OpenRouter generation cost 补账、Harbor adapter 接入、`--output-format`、`stream-json`、session/resume 与交互会话的多 run trajectory 命名,避免把不同控制面混入同一项能力。 From 307870f5f9cc08d88901b2994884d75705346703 Mon Sep 17 00:00:00 2001 From: minixalpha Date: Wed, 26 Aug 2026 22:42:41 +0800 Subject: [PATCH 3/5] docs: simplify public ATIF trajectory plan --- docs/dev_notes/zh-CN/0.8.x.md | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/dev_notes/zh-CN/0.8.x.md b/docs/dev_notes/zh-CN/0.8.x.md index 2613a5e..f0d72de 100644 --- a/docs/dev_notes/zh-CN/0.8.x.md +++ b/docs/dev_notes/zh-CN/0.8.x.md @@ -180,13 +180,22 @@ agent loop 现在在 user、model、tool 与 run 边界发出事件,另外用 #### 公开 ATIF trajectory -公开 ATIF trajectory 的完成标准是:一次 headless run 能够在不改变 stdout 的前提下,按用户指定路径产生一份可验证的 ATIF-v1.7 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 不属于这项能力。 -1. **ATIF-v1.7 projector。** 重放一次 run 的 Journal Entry,将 `user.message` 映射为 user step,将每次 `model.completed` 及通过 `model_call_id`/`tool_call_id` 关联的 tool calls/results 折叠成 agent step。根级使用 `schema_version: "ATIF-v1.7"`,把 Journal Entry 的 `run_id` 映射为 `session_id`,并把 `run.started.producer.name/version` 映射为必填的 `agent.name/version`,把 `run.started.model` 映射为可选的 `agent.model_name`;step ID 从 1 严格连续递增。 -2. **字段归因。** step 映射 message、model 和 tool arguments/result;tool error、stop reason 和 duration 没有对应的 ATIF 标准字段,分别放入 observation result 或 step `extra`。ATIF timestamp 优先使用可信 `source_timestamp`,否则回退到 Journal Entry 的 `recorded_at`,并在 `extra.timestamp_source` 中记录来源。`prompt_tokens` 包含非缓存、cache creation 和 cache read tokens,`cached_tokens` 只记录 cache hits。只有所有模型调用的对应 token 值都完整且可归因时,`final_metrics` 才写入对应 total;否则省略受影响的 total,并在 `final_metrics.extra.usage_status` 中标记 `partial`,不把部分和冒充完整总量。截断信息、run 终态与其他非标准运行信息进入各级 `extra`。 -3. **cost 边界。** 公开 ATIF trajectory 只消费 provider-neutral 的 amount、currency、source 与 resolved/partial 事实,不把 provider payload 中任意名为 `cost` 的字段直接投影成真实美元费用。在真实 cost 契约提供这些事实之前,所有 step 省略 `cost_usd`,`final_metrics` 省略 `total_cost_usd` 并在其 `extra.cost_status` 中记录 `unavailable`,不用 `0` 表示未知。OpenRouter Generation API 查询与 `model.cost_resolved` 属于真实 cost 补账能力。 -4. **`--trajectory PATH` CLI。** 参数出现即为本次 run 启用 projector,参数值是 ATIF JSON 文件的精确位置;不传参数时不生成公开 trajectory。`--trajectory` 只支持由 `-p`、`--prompt-file` 或 stdin 启动的 headless run;没有任务的交互会话会在一个进程中产生多个 run,在定义多 run 命名契约之前,与 `--trajectory` 组合时按 CLI usage error 拒绝。 -5. **路径与快照安全。** `PATH` 表示文件而不是目录,拒绝 `-`、已存在的目标和不存在的父目录,并在开始模型调用前失败,避免为无法保存的运行付费。目标在 Unix 上使用 `0600` 权限。只有已包含 `user.message` 的 Journal 前缀才能生成 ATIF 要求的至少一个 step;首份快照在 `user.message` 后写入。不含 tool calls 的 `model.completed` 可直接形成下一份快照;含 tool calls 时,要等该模型调用的所有工具都产生 `tool.completed` 后再 checkpoint,不对外暴露缺少 observation 的半完成 agent step。`run.completed` 或 `run.failed` 之后总是再写一次最终快照;失败 run 中无法完成的 tool call 作为已知终态记录在 `extra`。每次都用同目录临时文件加原子 rename 更新一份 schema-valid 快照,不暴露半写 JSON。 -6. **验收。** projector 单元测试覆盖纯文本回复、多轮/多工具调用、失败工具、max-turns 终态、run failure、ID 关联、timestamp 回退、截断元数据、token/cache 映射与未知 cost。CLI 测试覆盖 stdout 完全不变、无参数时不生成公开文件、路径误用、`0600` 权限、原子更新和失败 run 仍保留最后一份完整快照。根项目不增加 Harbor 运行时依赖;`benchmarks/harbor/` 可用已 pin 的 Harbor 0.21.0 ATIF 模型做 schema 契约验证,但不修改 adapter 的采集逻辑,也不跑真实 trial。 +**协议依据:**目标格式以 Harbor 0.21.0 中的 ATIF-v1.7 为准: -**非目标:**公开 ATIF trajectory 不包含 OpenRouter generation cost 补账、Harbor adapter 接入、`--output-format`、`stream-json`、session/resume 与交互会话的多 run trajectory 命名,避免把不同控制面混入同一项能力。 +- [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 接入验收。 From f3391e9568cb41c5d1d182119ece067ce4621c7c Mon Sep 17 00:00:00 2001 From: minixalpha Date: Wed, 26 Aug 2026 22:46:30 +0800 Subject: [PATCH 4/5] docs: simplify event journal development notes --- docs/dev_notes/zh-CN/0.8.x.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/dev_notes/zh-CN/0.8.x.md b/docs/dev_notes/zh-CN/0.8.x.md index f0d72de..a203f19 100644 --- a/docs/dev_notes/zh-CN/0.8.x.md +++ b/docs/dev_notes/zh-CN/0.8.x.md @@ -174,9 +174,20 @@ journal writer 接收它以后,补上持久化所需的身份、顺序和记 #### 运行事实与 Event Journal -schema version 1 的完整 wire contract、事件语义、持久化行为与兼容性边界见 [Event Journal 实现协议 v1](../../dev_docs/zh-CN/event-journal-protocol-v1.md)。`event_journal.py` 定义 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 不受影响。 +**要做什么:**建立版本化的 Native Event 与 Journal Entry 契约,让 agent loop 只产生运行事实,再为每次 Agent Run 追加写入一份可重放的内部 Event Journal。现有 stdout 文本也从同一组事实投影,但 Event Journal 本身是敏感的内部重建数据,不是公开 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` 的公开格式。 +**协议依据:**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 From 59485cd1ce7f6c347c939e772ad70ecd9c8e4c95 Mon Sep 17 00:00:00 2001 From: minixalpha Date: Wed, 26 Aug 2026 23:16:48 +0800 Subject: [PATCH 5/5] feat: add ATIF trajectory export --- README.md | 14 + README.zh-CN.md | 13 + benchmarks/harbor/pyproject.toml | 4 + .../tests/fixtures/atif-journal-v1.jsonl | 7 + .../harbor/tests/test_atif_compatibility.py | 15 + benchmarks/harbor/uv.lock | 97 ++++- docs/changelogs/0.8.x.md | 4 + src/nanopycodeagent/agent.py | 35 +- src/nanopycodeagent/atif.py | 387 ++++++++++++++++++ src/nanopycodeagent/cli.py | 31 +- tests/test_atif.py | 353 ++++++++++++++++ tests/test_cli.py | 121 ++++++ 12 files changed, 1070 insertions(+), 11 deletions(-) create mode 100644 benchmarks/harbor/tests/fixtures/atif-journal-v1.jsonl create mode 100644 benchmarks/harbor/tests/test_atif_compatibility.py create mode 100644 src/nanopycodeagent/atif.py create mode 100644 tests/test_atif.py 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..6011294 100644 --- a/docs/changelogs/0.8.x.md +++ b/docs/changelogs/0.8.x.md @@ -32,6 +32,10 @@ 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 and refusing to overwrite an existing file. ### Fixed - Declare `httpx` as a direct runtime dependency so clean and containerized 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..22e4274 --- /dev/null +++ b/src/nanopycodeagent/atif.py @@ -0,0 +1,387 @@ +"""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 _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 + 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 native_tool_call in 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_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"] + 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) + steps.append( + { + "step_id": len(steps) + 1, + "timestamp": timestamp, + "source": "user", + "message": _message(payload["content"]), + "extra": { + "message_id": payload["message_id"], + "timestamp_source": timestamp_source, + }, + } + ) + 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) + 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": { + "model_call_id": model_call_id, + "incomplete": True, + "started_at": started_at, + "started_at_source": started_at_source, + "timestamp_source": timestamp_source, + }, + } + ) + + 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"] + + 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..7f483ac --- /dev/null +++ b/tests/test_atif.py @@ -0,0 +1,353 @@ +"""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_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([])))