fix: recover Work runs after failed verification

This commit is contained in:
wuyang
2026-07-26 13:37:51 +08:00
parent 2f6f46c2fa
commit 8a31a0edb6
7 changed files with 331 additions and 25 deletions
+28 -9
View File
@@ -22,6 +22,18 @@ from agent_platform.runtime.schemas import ChatCompletionRequest
from agent_platform.runtime.tools import ToolRegistry from agent_platform.runtime.tools import ToolRegistry
from agent_platform.store import RuntimeStore from agent_platform.store import RuntimeStore
OPENWEBUI_BACKGROUND_TASKS = {
"title_generation",
"follow_up_generation",
"tags_generation",
"emoji_generation",
"query_generation",
"image_prompt_generation",
"autocomplete_generation",
"function_calling",
"moa_response_generation",
}
def _sse_chunk(model: str, content: str = "", finish_reason: str | None = None) -> bytes: def _sse_chunk(model: str, content: str = "", finish_reason: str | None = None) -> bytes:
payload = { payload = {
@@ -145,15 +157,7 @@ def create_app(
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
stable_chat_id = (chat_id or message_id or f"ephemeral-{uuid.uuid4().hex}").strip() async def forward_direct():
selected_mode = await app.state.store.select_mode(identity.user_id, chat_id or "", spec.mode)
if selected_mode == "work" and spec.mode == "chat":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This conversation has been upgraded to Work and cannot return to Chat.",
)
if spec.mode == "chat":
payload = body.model_dump(exclude_none=True) payload = body.model_dump(exclude_none=True)
payload["model"] = spec.provider_model payload["model"] = spec.provider_model
response = await app.state.provider.forward(payload) response = await app.state.provider.forward(payload)
@@ -183,6 +187,21 @@ def create_app(
headers=headers, headers=headers,
) )
background_task = str((body.metadata or {}).get("task", "")).strip()
if background_task in OPENWEBUI_BACKGROUND_TASKS:
return await forward_direct()
stable_chat_id = (chat_id or message_id or f"ephemeral-{uuid.uuid4().hex}").strip()
selected_mode = await app.state.store.select_mode(identity.user_id, chat_id or "", spec.mode)
if selected_mode == "work" and spec.mode == "chat":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This conversation has been upgraded to Work and cannot return to Chat.",
)
if spec.mode == "chat":
return await forward_direct()
messages = [message.model_dump(exclude_none=True) for message in body.messages] messages = [message.model_dump(exclude_none=True) for message in body.messages]
if not body.stream: if not body.stream:
+85 -14
View File
@@ -32,6 +32,9 @@ relevant verification, and report concrete results. Use tools instead of inventi
command output. A claim that a file was created, changed, executed, or tested must be backed by a command output. A claim that a file was created, changed, executed, or tested must be backed by a
successful tool result from this run. For scripts, reports, code, and other deliverables, create real successful tool result from this run. For scripts, reports, code, and other deliverables, create real
files under /workspace, inspect them after writing, run relevant checks, and cite their exact paths. files under /workspace, inspect them after writing, run relevant checks, and cite their exact paths.
Write source files with real line breaks rather than literal `\\n` escape sequences. If any command
fails, inspect the failure, repair the underlying problem, and successfully rerun the relevant check;
never replace failed output with invented numbers, placeholders, or a hand-written success report.
Never use an interactive command such as bare `python3`, `bash`, or `node`; provide a complete Never use an interactive command such as bare `python3`, `bash`, or `node`; provide a complete
non-interactive command. Do not use `echo` merely to simulate progress. Keep the plan current. Use non-interactive command. Do not use `echo` merely to simulate progress. Keep the plan current. Use
background processes for servers or long jobs. Delegate bounded independent investigations when it background processes for servers or long jobs. Delegate bounded independent investigations when it
@@ -59,8 +62,15 @@ ACTION_REQUEST = re.compile(
r"\b(?:run|execute|test|inspect|analy[sz]e|compare|debug|deploy)\b)", r"\b(?:run|execute|test|inspect|analy[sz]e|compare|debug|deploy)\b)",
re.IGNORECASE, re.IGNORECASE,
) )
EXECUTION_REQUEST = re.compile(
r"(运行|执行|测试|脚本|程序|代码|比较|对比|基准|"
r"\b(?:run|execute|test|script|program|code|compare|benchmark)\b)",
re.IGNORECASE,
)
MUTATION_TOOLS = {"write_file", "apply_patch"} MUTATION_TOOLS = {"write_file", "apply_patch"}
VERIFICATION_TOOLS = {"read_file", "list_files", "search_files", "git_status", "git_diff", "exec", "poll_process"} VERIFICATION_TOOLS = {"read_file", "git_status", "git_diff", "exec", "poll_process"}
EXECUTION_TOOLS = {"exec", "start_process", "poll_process"}
MAX_CONSECUTIVE_CHECKPOINT_REJECTIONS = 3
def latest_user_text(messages: list[dict[str, Any]]) -> str: def latest_user_text(messages: list[dict[str, Any]]) -> str:
@@ -78,19 +88,49 @@ def latest_user_text(messages: list[dict[str, Any]]) -> str:
return "" return ""
def completion_failure(artifact_required: bool, mutation_count: int, verified_mutation: int) -> str | None: def completion_failure(
artifact_required: bool,
mutation_count: int,
verified_mutation: int,
action_required: bool = False,
execution_required: bool = False,
successful_executions: int = 0,
unresolved_execution_failure: str | None = None,
) -> str | None:
reasons: list[str] = []
if artifact_required and mutation_count == 0: if artifact_required and mutation_count == 0:
return ( reasons.append(
"The user requested a concrete deliverable, but this run has no successful file-writing tool call. " "The user requested a concrete deliverable, but this run has no successful file-writing tool call. "
"Do not claim completion. Create the requested files with write_file or apply_patch, then inspect " "Create the requested files with write_file or apply_patch."
"and verify them with tools."
) )
if artifact_required and verified_mutation < mutation_count: elif artifact_required and verified_mutation < mutation_count:
return ( reasons.append(
"Files changed, but no successful verification occurred after the latest change. Inspect the actual " "Files changed, but no successful verification occurred after the latest change. Inspect the actual "
"files and run the relevant check before claiming completion." "files and run the relevant check."
) )
return None if action_required and unresolved_execution_failure:
reasons.append(
"A command or process check failed and has not been followed by a successful execution. "
f"Repair and rerun it. Last failure: {unresolved_execution_failure}"
)
elif execution_required and successful_executions == 0:
reasons.append(
"The task requires a script, code, test, benchmark, or comparison, but no command or process "
"completed successfully. Run the relevant verification and use its real output."
)
if not reasons:
return None
return " Do not claim completion. ".join(reasons)
def recovery_message(failure: str) -> str:
return (
"Your attempted checkpoint was rejected by the execution policy. Continue the original task now. "
"You MUST call the appropriate tools in this response instead of repeating or paraphrasing a final answer. "
f"Evidence gap: {failure} "
"If a command failed, inspect and repair the real files, rerun the command successfully, then inspect the "
"resulting deliverables before finishing."
)
@dataclass(slots=True) @dataclass(slots=True)
@@ -147,7 +187,7 @@ class AgentLoop:
"run.created", "run.created",
{ {
"model_tier": spec.strength, "model_tier": spec.strength,
"strategy_version": "work-loop-v1", "strategy_version": "work-loop-v2",
"scheduler_version": "safe-parallel-v1", "scheduler_version": "safe-parallel-v1",
"context_policy": "recent-visible-v1", "context_policy": "recent-visible-v1",
}, },
@@ -200,9 +240,13 @@ class AgentLoop:
request_text = latest_user_text(messages) request_text = latest_user_text(messages)
artifact_required = depth == 0 and bool(ARTIFACT_REQUEST.search(request_text)) artifact_required = depth == 0 and bool(ARTIFACT_REQUEST.search(request_text))
action_required = depth == 0 and bool(ACTION_REQUEST.search(request_text)) action_required = depth == 0 and bool(ACTION_REQUEST.search(request_text))
execution_required = depth == 0 and bool(EXECUTION_REQUEST.search(request_text))
mutation_count = 0 mutation_count = 0
verified_mutation = -1 verified_mutation = -1
successful_tools = 0 successful_tools = 0
successful_executions = 0
unresolved_execution_failure: str | None = None
consecutive_checkpoint_rejections = 0
for iteration in range(max_iterations): for iteration in range(max_iterations):
await recorder.emit( await recorder.emit(
"model.requested", "model.requested",
@@ -228,13 +272,26 @@ class AgentLoop:
tool_calls = message.get("tool_calls") or [] tool_calls = message.get("tool_calls") or []
if not tool_calls: if not tool_calls:
candidate = str(message.get("content") or "").strip() candidate = str(message.get("content") or "").strip()
failure = completion_failure(artifact_required, mutation_count, verified_mutation) failure = completion_failure(
artifact_required,
mutation_count,
verified_mutation,
action_required,
execution_required,
successful_executions,
unresolved_execution_failure,
)
if not failure and action_required and successful_tools == 0: if not failure and action_required and successful_tools == 0:
failure = ( failure = (
"The user requested an executed or inspected result, but no tool completed successfully. " "The user requested an executed or inspected result, but no tool completed successfully. "
"Use the appropriate workspace tools and return evidence instead of an unverified answer." "Use the appropriate workspace tools and return evidence instead of an unverified answer."
) )
if failure and iteration + 1 < max_iterations: consecutive_checkpoint_rejections += 1
if (
failure
and iteration + 1 < max_iterations
and consecutive_checkpoint_rejections < MAX_CONSECUTIVE_CHECKPOINT_REJECTIONS
):
await recorder.emit( await recorder.emit(
"completion.rejected", "completion.rejected",
{"reason": failure, "iteration": iteration + 1, "depth": depth}, {"reason": failure, "iteration": iteration + 1, "depth": depth},
@@ -242,7 +299,7 @@ class AgentLoop:
messages.extend( messages.extend(
[ [
{"role": "assistant", "content": candidate}, {"role": "assistant", "content": candidate},
{"role": "system", "content": failure}, {"role": "user", "content": recovery_message(failure)},
] ]
) )
continue continue
@@ -257,6 +314,7 @@ class AgentLoop:
) )
return candidate return candidate
consecutive_checkpoint_rejections = 0
assistant_message = { assistant_message = {
"role": "assistant", "role": "assistant",
"content": message.get("content"), "content": message.get("content"),
@@ -279,6 +337,11 @@ class AgentLoop:
mutation_count += 1 mutation_count += 1
elif name in VERIFICATION_TOOLS and mutation_count: elif name in VERIFICATION_TOOLS and mutation_count:
verified_mutation = mutation_count verified_mutation = mutation_count
if name in EXECUTION_TOOLS:
successful_executions += 1
unresolved_execution_failure = None
elif name in EXECUTION_TOOLS:
unresolved_execution_failure = f"{name}: {public_tool_summary(result, 1000)}"
messages.append( messages.append(
{ {
"role": "tool", "role": "tool",
@@ -299,7 +362,15 @@ class AgentLoop:
) )
response = await self.provider.complete(model=spec.provider_model, messages=messages, tools=None) response = await self.provider.complete(model=spec.provider_model, messages=messages, tools=None)
answer = str(response["choices"][0].get("message", {}).get("content") or "").strip() answer = str(response["choices"][0].get("message", {}).get("content") or "").strip()
if completion_failure(artifact_required, mutation_count, verified_mutation): if completion_failure(
artifact_required,
mutation_count,
verified_mutation,
action_required,
execution_required,
successful_executions,
unresolved_execution_failure,
):
return ( return (
"未完成:工具迭代已用尽,但交付物没有形成完整的“写入后验证”证据链。" "未完成:工具迭代已用尽,但交付物没有形成完整的“写入后验证”证据链。"
"我不会声称文件已经完成;工作区中已有内容可从“工作区文件”查看。" "我不会声称文件已经完成;工作区中已有内容可从“工作区文件”查看。"
+1
View File
@@ -23,6 +23,7 @@ class ChatCompletionRequest(BaseModel):
stream: bool = False stream: bool = False
tools: list[dict[str, Any]] | None = None tools: list[dict[str, Any]] | None = None
tool_choice: Any = None tool_choice: Any = None
metadata: dict[str, Any] | None = None
class PlanItem(BaseModel): class PlanItem(BaseModel):
+5
View File
@@ -39,6 +39,11 @@ services:
TASK_MODEL: chat-light TASK_MODEL: chat-light
ENABLE_TITLE_GENERATION: "false" ENABLE_TITLE_GENERATION: "false"
ENABLE_FOLLOW_UP_GENERATION: "false" ENABLE_FOLLOW_UP_GENERATION: "false"
ENABLE_TAGS_GENERATION: "false"
ENABLE_SEARCH_QUERY_GENERATION: "false"
ENABLE_RETRIEVAL_QUERY_GENERATION: "false"
ENABLE_AUTOCOMPLETE_GENERATION: "false"
ENABLE_IMAGE_PROMPT_GENERATION: "false"
ENABLE_OLLAMA_API: "false" ENABLE_OLLAMA_API: "false"
ENABLE_DIRECT_CONNECTIONS: "false" ENABLE_DIRECT_CONNECTIONS: "false"
ENABLE_PERSISTENT_CONFIG: "false" ENABLE_PERSISTENT_CONFIG: "false"
+5
View File
@@ -39,6 +39,11 @@ services:
TASK_MODEL: chat-light TASK_MODEL: chat-light
ENABLE_TITLE_GENERATION: "false" ENABLE_TITLE_GENERATION: "false"
ENABLE_FOLLOW_UP_GENERATION: "false" ENABLE_FOLLOW_UP_GENERATION: "false"
ENABLE_TAGS_GENERATION: "false"
ENABLE_SEARCH_QUERY_GENERATION: "false"
ENABLE_RETRIEVAL_QUERY_GENERATION: "false"
ENABLE_AUTOCOMPLETE_GENERATION: "false"
ENABLE_IMAGE_PROMPT_GENERATION: "false"
ENABLE_OLLAMA_API: "false" ENABLE_OLLAMA_API: "false"
ENABLE_DIRECT_CONNECTIONS: "false" ENABLE_DIRECT_CONNECTIONS: "false"
ENABLE_PERSISTENT_CONFIG: "false" ENABLE_PERSISTENT_CONFIG: "false"
+188 -2
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import copy
import json import json
from typing import Any from typing import Any
@@ -25,7 +26,7 @@ class ScriptedProvider:
self.requests = [] self.requests = []
async def complete(self, **kwargs): async def complete(self, **kwargs):
self.requests.append(kwargs) self.requests.append(copy.deepcopy(kwargs))
return self.responses.pop(0) return self.responses.pop(0)
@@ -203,7 +204,7 @@ async def test_artifact_completion_is_rejected_until_written_and_verified(settin
try: try:
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run( answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
spec=get_model_spec("work-light"), spec=get_model_spec("work-light"),
messages=[{"role": "user", "content": "写一个脚本并生成报告"}], messages=[{"role": "user", "content": "写一个报告"}],
identity=UserIdentity("u1", "", "", "user"), identity=UserIdentity("u1", "", "", "user"),
raw_user_jwt="jwt", raw_user_jwt="jwt",
chat_id="c1", chat_id="c1",
@@ -212,6 +213,191 @@ async def test_artifact_completion_is_rejected_until_written_and_verified(settin
assert answer == "已完成 report.md" assert answer == "已完成 report.md"
assert [name for name, _ in registry.calls] == ["write_file", "read_file"] assert [name for name, _ in registry.calls] == ["write_file", "read_file"]
assert "completion.rejected" in events assert "completion.rejected" in events
assert provider.requests[1]["messages"][-1]["role"] == "user"
assert "MUST call" in provider.requests[1]["messages"][-1]["content"]
finally:
await store.close()
class FailingExecutionRegistry(RecordingRegistry):
def __init__(self) -> None:
super().__init__()
self.exec_calls = 0
async def execute(self, name, arguments, context):
self.calls.append((name, arguments))
if name == "exec":
self.exec_calls += 1
if self.exec_calls == 1:
return {"ok": False, "error": "SyntaxError: invalid source"}
return {"ok": True, "output": f"{name} complete"}
async def test_failed_execution_must_be_repaired_before_completion(settings) -> None:
write = {
"choices": [
{
"message": {
"role": "assistant",
"tool_calls": [tool_call("1", "write_file", {"path": "sort.py", "content": "broken"})],
},
"finish_reason": "tool_calls",
}
]
}
read = {
"choices": [
{
"message": {
"role": "assistant",
"tool_calls": [tool_call("2", "read_file", {"path": "sort.py"})],
},
"finish_reason": "tool_calls",
}
]
}
failed_exec = {
"choices": [
{
"message": {
"role": "assistant",
"tool_calls": [tool_call("3", "exec", {"command": "python3 sort.py"})],
},
"finish_reason": "tool_calls",
}
]
}
premature = {"choices": [{"message": {"role": "assistant", "content": "已完成"}, "finish_reason": "stop"}]}
repaired_exec = {
"choices": [
{
"message": {
"role": "assistant",
"tool_calls": [tool_call("4", "exec", {"command": "python3 sort.py"})],
},
"finish_reason": "tool_calls",
}
]
}
final = {
"choices": [{"message": {"role": "assistant", "content": "脚本运行和报告验证完成"}, "finish_reason": "stop"}]
}
provider = ScriptedProvider([write, read, failed_exec, premature, repaired_exec, final])
registry = FailingExecutionRegistry()
store = RuntimeStore(settings.database_url)
await store.initialize()
events = []
async def callback(event_type, payload):
events.append(event_type)
try:
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
spec=get_model_spec("work-light"),
messages=[{"role": "user", "content": "写脚本对比排序算法并生成报告"}],
identity=UserIdentity("u1", "", "", "user"),
raw_user_jwt="jwt",
chat_id="c1",
callback=callback,
)
assert answer == "脚本运行和报告验证完成"
assert registry.exec_calls == 2
assert events.count("completion.rejected") == 1
recovery = provider.requests[4]["messages"][-1]
assert recovery["role"] == "user"
assert "SyntaxError" in recovery["content"]
finally:
await store.close()
async def test_script_delivery_requires_successful_execution(settings) -> None:
write = {
"choices": [
{
"message": {
"role": "assistant",
"tool_calls": [tool_call("1", "write_file", {"path": "script.py", "content": "print('ok')"})],
},
"finish_reason": "tool_calls",
}
]
}
read = {
"choices": [
{
"message": {
"role": "assistant",
"tool_calls": [tool_call("2", "read_file", {"path": "script.py"})],
},
"finish_reason": "tool_calls",
}
]
}
premature = {"choices": [{"message": {"role": "assistant", "content": "脚本完成"}, "finish_reason": "stop"}]}
execute = {
"choices": [
{
"message": {
"role": "assistant",
"tool_calls": [tool_call("3", "exec", {"command": "python3 script.py"})],
},
"finish_reason": "tool_calls",
}
]
}
final = {"choices": [{"message": {"role": "assistant", "content": "脚本执行验证完成"}, "finish_reason": "stop"}]}
provider = ScriptedProvider([write, read, premature, execute, final])
registry = RecordingRegistry()
store = RuntimeStore(settings.database_url)
await store.initialize()
async def callback(event_type, payload):
return None
try:
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
spec=get_model_spec("work-light"),
messages=[{"role": "user", "content": "写一个 Python 脚本"}],
identity=UserIdentity("u1", "", "", "user"),
raw_user_jwt="jwt",
chat_id="c1",
callback=callback,
)
assert answer == "脚本执行验证完成"
assert [name for name, _ in registry.calls] == ["write_file", "read_file", "exec"]
assert "no command or process completed successfully" in provider.requests[3]["messages"][-1]["content"]
finally:
await store.close()
async def test_rejected_checkpoint_does_not_spin_without_tool_calls(settings) -> None:
stopped = {"choices": [{"message": {"role": "assistant", "content": "已完成"}, "finish_reason": "stop"}]}
provider = ScriptedProvider([stopped, stopped, stopped])
store = RuntimeStore(settings.database_url)
await store.initialize()
events = []
async def callback(event_type, payload):
events.append(event_type)
try:
answer = await AgentLoop(
provider,
RecordingRegistry(),
store,
max_tool_output_chars=10_000,
).run(
spec=get_model_spec("work-light"),
messages=[{"role": "user", "content": "写一个报告"}],
identity=UserIdentity("u1", "", "", "user"),
raw_user_jwt="jwt",
chat_id="c1",
callback=callback,
)
assert answer.startswith("未完成:")
assert len(provider.requests) == 3
assert events.count("completion.rejected") == 2
assert events.count("completion.unverified") == 1
finally: finally:
await store.close() await store.close()
+19
View File
@@ -136,6 +136,25 @@ def test_chat_stream_hides_provider_model_and_preserves_native_tool_calls(settin
assert "data: [DONE]" in response.text assert "data: [DONE]" in response.text
def test_openwebui_background_task_bypasses_work_loop(settings, identity_jwt) -> None:
provider = FakeModelProvider()
app = create_app(settings, store=RuntimeStore(settings.database_url), provider=provider, tools=FakeTools())
with TestClient(app) as client:
response = client.post(
"/v1/chat/completions",
headers=headers(settings, identity_jwt, "background-task"),
json={
"model": "work-medium",
"messages": [{"role": "user", "content": "Generate tags"}],
"metadata": {"task": "tags_generation"},
},
)
assert response.status_code == 200
assert response.json()["model"] == "work-medium"
assert provider.completed == []
assert provider.forwarded[-1]["model"] == "ChatGPT-5.6:Terra"
def test_work_stream_never_emits_delta_tool_calls(settings, identity_jwt) -> None: def test_work_stream_never_emits_delta_tool_calls(settings, identity_jwt) -> None:
app = create_app( app = create_app(
settings, settings,