fix(agent): remove implicit workspace result hydration

This commit is contained in:
Darkatse 2026-05-30 21:50:04 +08:00
parent 3424bb6509
commit d246ac2ef8
8 changed files with 76 additions and 133 deletions

View file

@ -207,7 +207,7 @@ Tool result 当前会编码为 JSON 字符串,包含:
}
```
前 5 轮 `workspace.write_file` / `workspace.apply_patch` 成功结果会被 runtime hydrate:下一轮模型看到完整文件内容,并同步为完整 read-state;journal/workspace 真相不变。
`workspace.write_file` / `workspace.apply_patch` 成功结果不会被 runtime 自动补入完整文件内容。下一轮模型只看到 canonical tool result 摘要、结构化元数据与 resource refs;需要完整文件内容时必须通过 `workspace.read_file` 显式读取。
## 8. Policy
@ -275,7 +275,7 @@ Agent runtime 会按 Profile `run.modelRetry` 重试 `429` rate limit 与 transi
- missing tool call id fail-fast。
- Gemini schema sanitizer。
- Agent loop 通过 canonical response 推进。
- 前 5 轮 workspace write result hydration。
- workspace write/patch result 不再隐式补入完整内容,后续编辑依赖显式 read-state。
- OpenAI Responses native output items 回放。
- OpenAI Responses `provider_state.previousResponseId` 注入与 `messageCursor` 增量输入。
- Claude / Gemini native continuation 计数与缺失 fail-fast。

View file

@ -143,7 +143,6 @@ tool_call_failed
workspace_file_written
workspace_patch_applied
checkpoint_created
context_tool_result_hydrated
agent_loop_finished
artifact_assembled
commit_started
@ -325,7 +324,6 @@ tool_call_denied
tool_call_started
tool_call_completed
tool_call_failed
context_tool_result_hydrated
```
`tool_call_requested`:
@ -564,7 +562,6 @@ tool_call_failed
workspace_file_written
workspace_patch_applied
checkpoint_created
context_tool_result_hydrated
agent_loop_finished
artifact_assembled
commit_started

View file

@ -522,7 +522,7 @@ result returns to backend journal
- agent list/delegate/await 与 runtime-only task.return 的 return-mode SubAgent MVP。
- tool arguments / tool results resource refs。
- recoverable tool error 回填模型。
- 前 5 轮 workspace write/patch 成功结果会 hydrate 完整文件内容到下一轮模型上下文,并同步为完整 read-state。
- workspace write/patch 成功结果只回填摘要、结构化元数据与 resource refs;需要完整内容时由模型显式调用 workspace_read_file。
- workspace mutation checkpoint。
- journal events。

View file

@ -152,7 +152,7 @@ LLM Gateway / provider adapter
- `AgentModelGateway` 仍复用 `ChatCompletionService::generate_exchange_with_cancel()`,在 canonical IR 与现有 provider payload pipeline 之间转换。
- Claude / Gemini / OpenAI Responses / Gemini Interactions 的 native metadata 以 opaque `Native` part 保存和回放;tool call id 缺失会 fail-fast。
- Agent `provider_state` 已用于 run-scoped continuation;OpenAI Responses 通过 persistent WebSocket、incremental input 与 `previous_response_id` 续接。详见 `docs/CurrentState/AgentProviderState.md`。
- 前 5 轮 `workspace.write_file` / `workspace.apply_patch` 成功结果会把完整文件内容 hydrate 到下一轮模型上下文。
- `workspace.write_file` / `workspace.apply_patch` 成功结果只回填摘要、结构化元数据与 resource refs;需要完整内容时模型必须显式调用 `workspace.read_file`。
- `chat.search` 与 `chat.read_messages` 只读取当前 run 绑定的聊天,不允许模型指定任意 chat target;message index 从 0 开始,JSONL header 不计入消息。
- `worldinfo.read_activated` 只读取本次 run prompt snapshot 中 materialized 的激活结果,不把全局 last activation 当作运行时真相。
- 当前模型可见 / 可写 workspace 根由 run manifest roots 驱动,默认包含 `output/`、`scratch/`、`plan/`、`summaries/`、`persist/`;`persist/` 是 chat workspace 级持久 root 的 run projection,`workspace.finish` 收尾成功后 promote 回稳定 chat workspace;`input/`、`tool-args/`、`tool-results/`、`model-responses/`、`checkpoints/` 与 `events.jsonl` 不作为模型工具资源暴露。
@ -435,6 +435,6 @@ SillyTavern 上游的事件和 chat message 结构仍是兼容层的基础。Age
9. Agent Mode off 行为完全不变。
10. canonical model IR 与 `AgentModelGateway`。
11. provider native metadata opaque 保留/回放。
12. recent workspace write/patch tool result hydration。
12. workspace write/patch read-state 与显式 read-before-edit 语义。
下一步的架构重点不再是证明 Agent loop 可行,而是补齐三个长期能力:更清晰的 provider adapter 模块、创作者可控的 profile/context policy、可理解的 timeline/diff/rollback UI。

View file

@ -166,11 +166,11 @@ Tool registry 只产 canonical `AgentToolSpec`,不再暴露 OpenAI-shaped `ope
- Recoverable tool error:模型参数、路径字符串、可见/可写策略、文件不存在、chat message index 不存在、读取范围非法、结果超过工具预算、patch 未完整读取、sha 过期、匹配 0 次或多次等模型可修正问题。返回 `AgentToolResult { is_error: true }`,写入 `tool_call_failed` warn event,并作为 tool message 回填下一轮模型。
- Fatal runtime error:journal 写入失败、workspace repository 内部 IO 错误、chat JSONL 损坏、manifest/checkpoint 损坏、模型响应结构不可解析、取消、序列化失败、状态机错误等宿主级问题。直接让 run 进入 failed 或 cancelled。
当前已落地 recent hydration:
当前工具结果不做自动内容补入:
- 前 5 轮 `workspace.write_file` / `workspace.apply_patch` 成功结果会读取目标 workspace 文件,将完整文本加入下一轮模型上下文,并同步为完整 read-state。
- hydration 只影响 model request,不改变 workspace/journal 真相。
- hydration 会写 `context_tool_result_hydrated` debug event。
- `workspace.write_file` / `workspace.apply_patch` 成功结果只以 tool result 摘要、结构化元数据与 resource refs 回填模型。
- runtime 不再自动读取完整 workspace 文件内容并拼入下一轮 model request。
- 后续 rewrite / patch 依赖 workspace 工具维护的 read-state;模型需要完整文件内容时必须显式调用 `workspace.read_file`。
## 7. 当前运行流

View file

@ -6,7 +6,7 @@
## 当前基线
截至 2026-05-28,Agent 当前基线:
截至 2026-05-30,Agent 当前基线:
- Rust 后端已有 Agent domain model、runtime、workspace、journal、checkpoint、commit bridge。
- 前端已挂载 `window.__TAURITAVERN__.api.agent` Host ABI。
@ -209,13 +209,14 @@ AgentModelContentPart {
- journal / workspace 保存的是真实 tool result、tool args、resource refs。
- 下一轮模型上下文使用 canonical `ToolResult` part。
当前已落地 recent hydration:
当前不再自动补入 workspace 写入内容:
- 前 5 轮中,`workspace.write_file` 与 `workspace.apply_patch` 成功后,会把目标文件当前完整内容回填到下一轮模型上下文,并同步为完整 read-state。
- 该回填只影响 model request,不改变实际 workspace/journal 真相。
- hydration 会写 `context_tool_result_hydrated` debug event。
- `workspace.write_file` 与 `workspace.apply_patch` 成功结果只把摘要、结构化元数据与 resource refs 作为 canonical `ToolResult` 回填模型。
- runtime 不会因前几轮 workspace 写入而自动读取完整文件内容并拼入下一轮模型上下文。
- 模型需要完整文件内容时必须显式调用 `workspace.read_file`;append 到未完整读过的旧文件后,后续 rewrite / patch 会按 read-state 规则要求重新读取。
- read-state 由 workspace 工具维护:完整读取、创建/完整替换、成功 patch 会记录完整已知;append 只有在旧内容已经完整已知时才延续完整已知。
这样避免模型/provider 切换后只看到写入摘要而丢失刚写入的真实文本;面向模型与 UI 的摘要统一使用 chars/words,字节数只保留在内部存储与完整性边界。
这样让跨 Agent / 跨模型协作依赖显式 task packet、workspace refs 与 invocation-scoped prompt,而不是旧的 provider switch 最近历史启发式。
## Workspace 与 Commit
@ -343,7 +344,6 @@ tool_call_completed / tool_call_failed
workspace_file_written
workspace_patch_applied
checkpoint_created
context_tool_result_hydrated
provider_state_updated
model_response_stored
agent_loop_finished

View file

@ -19,7 +19,6 @@ use crate::domain::models::agent::{
WorkspacePath,
};
use crate::domain::models::skill::SkillIndexEntry;
use crate::domain::repositories::workspace_repository::WorkspaceRepository;
use crate::domain::text_metrics::TextMetrics;
/// How many in-loop drift recovery attempts to make per run before
@ -318,30 +317,6 @@ impl AgentRuntimeService {
return Ok(Some(commit_count));
}
let tool_results = if exit_policy == AgentInvocationExitPolicy::TaskReturnRequired {
let workspace_view = self
.child_workspace_view(run_id, invocation_id, profile)
.await?;
let workspace_repository =
workspace_view.repository(self.workspace_repository.as_ref());
self.hydrate_recent_tool_results_for_model(
run_id,
round,
&tool_results,
&workspace_repository,
&mut tool_session,
)
.await?
} else {
self.hydrate_recent_tool_results_for_model(
run_id,
round,
&tool_results,
self.workspace_repository.as_ref(),
&mut tool_session,
)
.await?
};
remember_seen_child_results_from_await(&tool_results, &mut seen_child_result_task_ids);
append_tool_turn_to_request(&mut request, assistant_message, &tool_results)?;
if exit_policy == AgentInvocationExitPolicy::RunFinishAllowed {
@ -405,72 +380,6 @@ impl AgentRuntimeService {
Ok(Some(file.path))
}
async fn hydrate_recent_tool_results_for_model(
&self,
run_id: &str,
round: usize,
tool_results: &[AgentToolResult],
workspace_repository: &dyn WorkspaceRepository,
tool_session: &mut AgentToolSession,
) -> Result<Vec<AgentToolResult>, ApplicationError> {
if round > 5 {
return Ok(tool_results.to_vec());
}
let mut hydrated = Vec::with_capacity(tool_results.len());
for result in tool_results {
let mut result = result.clone();
if result.is_error
|| !(result.name == "workspace.write_file"
|| result.name == "workspace.apply_patch")
{
hydrated.push(result);
continue;
}
let Some(path) = result
.structured
.get("path")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
hydrated.push(result);
continue;
};
let workspace_path = WorkspacePath::parse(path)?;
let file = workspace_repository
.read_text(run_id, &workspace_path)
.await?;
tool_session.remember_file(&file, true);
result.content = format!(
"{}\n\nFull content of {}:\n{}",
result.content,
file.path.as_str(),
file.text
);
self.event(
run_id,
AgentRunEventLevel::Debug,
"context_tool_result_hydrated",
{
let metrics = TextMetrics::from_text(&file.text);
json!({
"round": round,
"callId": result.call_id.as_str(),
"path": file.path.as_str(),
"chars": metrics.chars,
"words": metrics.words,
})
},
)
.await?;
hydrated.push(result);
}
Ok(hydrated)
}
}
fn remember_seen_child_results_from_await(

View file

@ -2492,7 +2492,7 @@ async fn agent_loop_writes_artifact_and_completes() {
let model_requests = model_gateway_probe.requests().await;
let second_request = model_requests.get(1).expect("second model request");
let hydrated_tool_result = second_request
let write_tool_result = second_request
.messages
.iter()
.find(|message| message.role == AgentModelRole::Tool)
@ -2501,13 +2501,12 @@ async fn agent_loop_writes_artifact_and_completes() {
AgentModelContentPart::ToolResult { result } => Some(result),
_ => None,
})
.expect("hydrated tool result");
assert!(
hydrated_tool_result
.content
.contains("Full content of output/main.md")
.expect("write tool result");
assert_eq!(
write_tool_result.content,
"Wrote 15 chars / 3 words to output/main.md."
);
assert!(hydrated_tool_result.content.contains("hello from loop"));
assert!(!write_tool_result.content.contains("hello from loop"));
wait_for_closed_sessions(
&model_gateway_probe,
vec!["run_loop_test:inv_root".to_string()],
@ -2562,16 +2561,16 @@ async fn agent_loop_writes_artifact_and_completes() {
}
#[tokio::test]
async fn agent_loop_hydrated_append_result_unlocks_next_round_rewrite() {
async fn agent_loop_explicit_read_after_append_unlocks_rewrite() {
let root = std::env::temp_dir().join(format!(
"tauritavern-agent-append-hydrate-{}",
"tauritavern-agent-append-read-{}",
Uuid::new_v4().simple()
));
let repository = Arc::new(FileAgentRepository::new(root.clone()));
let run = AgentRun {
id: "run_append_hydrate_test".to_string(),
workspace_id: "chat_append_hydrate_test".to_string(),
stable_chat_id: "stable_append_hydrate_test".to_string(),
id: "run_append_read_test".to_string(),
workspace_id: "chat_append_read_test".to_string(),
stable_chat_id: "stable_append_read_test".to_string(),
chat_ref: AgentChatRef::Character {
character_id: "Seraphina".to_string(),
file_name: "Seraphina.png".to_string(),
@ -2641,11 +2640,27 @@ async fn agent_loop_hydrated_append_result_unlocks_next_round_rewrite() {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_replace_after_hydrate",
"id": "call_read_after_append",
"type": "function",
"function": {
"name": "workspace_read_file",
"arguments": "{\"path\":\"output/main.md\"}"
}
}]
}
}]
}),
json!({
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_replace_after_read",
"type": "function",
"function": {
"name": "workspace_write_file",
"arguments": "{\"path\":\"output/main.md\",\"content\":\"rewritten after hydrated append\",\"mode\":\"replace\"}"
"arguments": "{\"path\":\"output/main.md\",\"content\":\"rewritten after explicit read\",\"mode\":\"replace\"}"
}
}]
}
@ -2699,11 +2714,11 @@ async fn agent_loop_hydrated_append_result_unlocks_next_round_rewrite() {
.read_text(&run.id, &WorkspacePath::parse("output/main.md").unwrap())
.await
.expect("read artifact");
assert_eq!(artifact.text, "rewritten after hydrated append");
assert_eq!(artifact.text, "rewritten after explicit read");
let model_requests = model_gateway_probe.requests().await;
let second_request = model_requests.get(1).expect("second model request");
let hydrated_tool_result = second_request
let append_tool_result = second_request
.messages
.iter()
.find(|message| message.role == AgentModelRole::Tool)
@ -2712,16 +2727,38 @@ async fn agent_loop_hydrated_append_result_unlocks_next_round_rewrite() {
AgentModelContentPart::ToolResult { result } => Some(result),
_ => None,
})
.expect("hydrated append result");
assert!(
hydrated_tool_result
.content
.contains("Full content of output/main.md")
.expect("append result");
assert_eq!(
append_tool_result.content,
"Appended 12 chars / 2 words to output/main.md."
);
assert!(hydrated_tool_result.content.contains("first\nsecond"));
assert!(!append_tool_result.content.contains("first\nsecond"));
let third_request = model_requests.get(2).expect("third model request");
let read_tool_result = third_request
.messages
.iter()
.filter(|message| message.role == AgentModelRole::Tool)
.filter_map(|message| message.parts.first())
.find_map(|part| match part {
AgentModelContentPart::ToolResult { result }
if result.name == "workspace.read_file" =>
{
Some(result)
}
_ => None,
})
.expect("explicit read result");
assert!(
read_tool_result
.content
.contains("output/main.md lines 1-2 of 2")
);
assert!(read_tool_result.content.contains("first"));
assert!(read_tool_result.content.contains("second"));
wait_for_closed_sessions(
&model_gateway_probe,
vec!["run_append_hydrate_test:inv_root".to_string()],
vec!["run_append_read_test:inv_root".to_string()],
)
.await;