From cf7226ad9ec46c765d3565394c88dfe1e495c12b Mon Sep 17 00:00:00 2001 From: minixalpha Date: Wed, 26 Aug 2026 22:12:42 +0800 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/7] 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([]))) From 118443494e08f1473dcff4a68f21f00aee5a7d1d Mon Sep 17 00:00:00 2001 From: minixalpha Date: Sun, 30 Aug 2026 21:48:34 +0800 Subject: [PATCH 6/7] fix(atif): preserve journal truncation metadata --- docs/changelogs/0.8.x.md | 3 +- src/nanopycodeagent/atif.py | 85 ++++++++++++++++++--- tests/test_atif.py | 147 ++++++++++++++++++++++++++++++++++++ 3 files changed, 222 insertions(+), 13 deletions(-) diff --git a/docs/changelogs/0.8.x.md b/docs/changelogs/0.8.x.md index 6011294..08d62d9 100644 --- a/docs/changelogs/0.8.x.md +++ b/docs/changelogs/0.8.x.md @@ -35,7 +35,8 @@ All notable changes in the **0.8.x** release series are documented here. - 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. + 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/src/nanopycodeagent/atif.py b/src/nanopycodeagent/atif.py index 22e4274..869a6eb 100644 --- a/src/nanopycodeagent/atif.py +++ b/src/nanopycodeagent/atif.py @@ -24,6 +24,42 @@ def _timestamp(entry: JournalEntry) -> tuple[str, str]: 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 @@ -112,6 +148,7 @@ def _step_extra( extra["started_at"] = started_at extra["started_at_source"] = started_source extra["timestamp_source"] = timestamp_source + _add_journal_truncation(extra, entry, "/content") return extra @@ -126,7 +163,7 @@ def _tool_calls_and_observation( assert isinstance(native_tool_calls, list) tool_calls: list[JsonObject] = [] results: list[JsonObject] = [] - for native_tool_call in native_tool_calls: + 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) @@ -145,6 +182,15 @@ def _tool_calls_and_observation( "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) @@ -163,6 +209,7 @@ def _tool_calls_and_observation( } 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, @@ -212,16 +259,18 @@ def project_atif(entries: Sequence[JournalEntry]) -> JsonObject: 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": { - "message_id": payload["message_id"], - "timestamp_source": timestamp_source, - }, + "extra": extra, } ) elif entry.type == "model.started": @@ -271,6 +320,23 @@ def project_atif(entries: Sequence[JournalEntry]) -> JsonObject: 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, @@ -279,13 +345,7 @@ def project_atif(entries: Sequence[JournalEntry]) -> JsonObject: "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, - }, + "extra": extra, } ) @@ -307,6 +367,7 @@ def project_atif(entries: Sequence[JournalEntry]) -> JsonObject: 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, diff --git a/tests/test_atif.py b/tests/test_atif.py index 7f483ac..83d7abb 100644 --- a/tests/test_atif.py +++ b/tests/test_atif.py @@ -338,6 +338,153 @@ def test_tool_lifecycle_is_folded_into_the_originating_agent_step(tmp_path): } +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)) From 8f956b438974fe00d4ebece66671694c3e557fdc Mon Sep 17 00:00:00 2001 From: minixalpha Date: Sun, 30 Aug 2026 21:51:07 +0800 Subject: [PATCH 7/7] docs: sync English development notes for ATIF export --- docs/dev_notes/en/0.8.x.md | 54 +++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 10 deletions(-) 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.