mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-19 07:35:14 +00:00
fix(agent): remove implicit workspace result hydration
This commit is contained in:
parent
3424bb6509
commit
d246ac2ef8
8 changed files with 76 additions and 133 deletions
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -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. 当前运行流
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue