fix: require downloadable Work reports
This commit is contained in:
@@ -80,6 +80,8 @@ EXECUTION_REQUEST = re.compile(
|
|||||||
r"\b(?:run|execute|test|script|program|code|compare|benchmark)\b)",
|
r"\b(?:run|execute|test|script|program|code|compare|benchmark)\b)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
SOURCE_FILE_REQUEST = re.compile(r"(脚本|程序|代码|\b(?:script|program|code)\b)", re.IGNORECASE)
|
||||||
|
REPORT_FILE_REQUEST = re.compile(r"(报告|\breport\b)", re.IGNORECASE)
|
||||||
MUTATION_TOOLS = {"write_file", "apply_patch"}
|
MUTATION_TOOLS = {"write_file", "apply_patch"}
|
||||||
VERIFICATION_TOOLS = {"read_file", "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"}
|
EXECUTION_TOOLS = {"exec", "start_process", "poll_process"}
|
||||||
@@ -122,6 +124,7 @@ PLAIN_TEXT_FILE_SUFFIXES = {
|
|||||||
".yaml",
|
".yaml",
|
||||||
".yml",
|
".yml",
|
||||||
}
|
}
|
||||||
|
REPORT_FILE_SUFFIXES = {".md", ".rst", ".txt"}
|
||||||
READ_ONLY_EXEC_COMMAND = re.compile(
|
READ_ONLY_EXEC_COMMAND = re.compile(
|
||||||
r"^(?:cat|cut|diff|find|git\s+(?:diff|show|status)|grep|head|jq|ls|pwd|rg|sed\s+-n|stat|tail|wc)\b",
|
r"^(?:cat|cut|diff|find|git\s+(?:diff|show|status)|grep|head|jq|ls|pwd|rg|sed\s+-n|stat|tail|wc)\b",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
@@ -177,6 +180,7 @@ def completion_failure(
|
|||||||
execution_required: bool = False,
|
execution_required: bool = False,
|
||||||
successful_executions: int = 0,
|
successful_executions: int = 0,
|
||||||
unresolved_execution_failure: str | None = None,
|
unresolved_execution_failure: str | None = None,
|
||||||
|
deliverable_failure: str | None = None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
if artifact_required and mutation_count == 0:
|
if artifact_required and mutation_count == 0:
|
||||||
@@ -199,11 +203,45 @@ def completion_failure(
|
|||||||
"The task requires a script, code, test, benchmark, or comparison, but no command or process "
|
"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."
|
"completed successfully. Run the relevant verification and use its real output."
|
||||||
)
|
)
|
||||||
|
if deliverable_failure:
|
||||||
|
reasons.append(deliverable_failure)
|
||||||
if not reasons:
|
if not reasons:
|
||||||
return None
|
return None
|
||||||
return " Do not claim completion. ".join(reasons)
|
return " Do not claim completion. ".join(reasons)
|
||||||
|
|
||||||
|
|
||||||
|
def workspace_artifact_path(arguments: dict[str, Any]) -> str | None:
|
||||||
|
raw_path = arguments.get("path")
|
||||||
|
if not isinstance(raw_path, str) or not raw_path.strip():
|
||||||
|
return None
|
||||||
|
path = PurePosixPath(raw_path.strip())
|
||||||
|
try:
|
||||||
|
return str(path.relative_to("/workspace"))
|
||||||
|
except ValueError:
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def deliverable_evidence_failure(
|
||||||
|
*,
|
||||||
|
source_required: bool,
|
||||||
|
report_required: bool,
|
||||||
|
written_source_paths: set[str],
|
||||||
|
written_report_paths: set[str],
|
||||||
|
reports_written_after_execution: set[str],
|
||||||
|
verified_paths: set[str],
|
||||||
|
) -> str | None:
|
||||||
|
reasons: list[str] = []
|
||||||
|
if source_required and not written_source_paths:
|
||||||
|
reasons.append("Create a separate executable source file such as a `.py` file.")
|
||||||
|
if report_required and not written_report_paths:
|
||||||
|
reasons.append("Create a separate report file such as `report.md`.")
|
||||||
|
elif report_required and not reports_written_after_execution:
|
||||||
|
reasons.append("Write or update the report only after a successful execution, using its measured output.")
|
||||||
|
elif report_required and not (reports_written_after_execution & verified_paths):
|
||||||
|
reasons.append("Read the generated report file after writing it to verify the delivered content.")
|
||||||
|
return " ".join(reasons) or None
|
||||||
|
|
||||||
|
|
||||||
def recovery_message(failure: str) -> str:
|
def recovery_message(failure: str) -> str:
|
||||||
return (
|
return (
|
||||||
"Your attempted checkpoint was rejected by the execution policy. Continue the original task now. "
|
"Your attempted checkpoint was rejected by the execution policy. Continue the original task now. "
|
||||||
@@ -513,10 +551,16 @@ class AgentLoop:
|
|||||||
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))
|
execution_required = depth == 0 and bool(EXECUTION_REQUEST.search(request_text))
|
||||||
|
source_required = depth == 0 and bool(SOURCE_FILE_REQUEST.search(request_text))
|
||||||
|
report_required = depth == 0 and bool(REPORT_FILE_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
|
successful_executions = 0
|
||||||
|
written_source_paths: set[str] = set()
|
||||||
|
written_report_paths: set[str] = set()
|
||||||
|
reports_written_after_execution: set[str] = set()
|
||||||
|
verified_paths: set[str] = set()
|
||||||
unresolved_execution_failure: str | None = None
|
unresolved_execution_failure: str | None = None
|
||||||
failed_execution_revisions: dict[str, int] = {}
|
failed_execution_revisions: dict[str, int] = {}
|
||||||
successful_write_fingerprints: set[str] = set()
|
successful_write_fingerprints: set[str] = set()
|
||||||
@@ -554,6 +598,14 @@ class AgentLoop:
|
|||||||
execution_required,
|
execution_required,
|
||||||
successful_executions,
|
successful_executions,
|
||||||
unresolved_execution_failure,
|
unresolved_execution_failure,
|
||||||
|
deliverable_evidence_failure(
|
||||||
|
source_required=source_required,
|
||||||
|
report_required=report_required,
|
||||||
|
written_source_paths=written_source_paths,
|
||||||
|
written_report_paths=written_report_paths,
|
||||||
|
reports_written_after_execution=reports_written_after_execution,
|
||||||
|
verified_paths=verified_paths,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if not failure and action_required and successful_tools == 0:
|
if not failure and action_required and successful_tools == 0:
|
||||||
failure = (
|
failure = (
|
||||||
@@ -662,6 +714,17 @@ class AgentLoop:
|
|||||||
successful_tools += 1
|
successful_tools += 1
|
||||||
if name in MUTATION_TOOLS:
|
if name in MUTATION_TOOLS:
|
||||||
mutation_count += 1
|
mutation_count += 1
|
||||||
|
artifact_path = workspace_artifact_path(arguments) if isinstance(arguments, dict) else None
|
||||||
|
if name == "write_file" and artifact_path:
|
||||||
|
suffix = PurePosixPath(artifact_path).suffix.lower()
|
||||||
|
if suffix in CODE_FILE_SUFFIXES:
|
||||||
|
written_source_paths.add(artifact_path)
|
||||||
|
if suffix in REPORT_FILE_SUFFIXES:
|
||||||
|
written_report_paths.add(artifact_path)
|
||||||
|
if successful_executions or not execution_required:
|
||||||
|
reports_written_after_execution.add(artifact_path)
|
||||||
|
elif name == "read_file" and artifact_path:
|
||||||
|
verified_paths.add(artifact_path)
|
||||||
fingerprint = write_call_fingerprint(name, arguments) if isinstance(arguments, dict) else None
|
fingerprint = write_call_fingerprint(name, arguments) if isinstance(arguments, dict) else None
|
||||||
if fingerprint:
|
if fingerprint:
|
||||||
successful_write_fingerprints.add(fingerprint)
|
successful_write_fingerprints.add(fingerprint)
|
||||||
@@ -711,6 +774,14 @@ class AgentLoop:
|
|||||||
execution_required,
|
execution_required,
|
||||||
successful_executions,
|
successful_executions,
|
||||||
unresolved_execution_failure,
|
unresolved_execution_failure,
|
||||||
|
deliverable_evidence_failure(
|
||||||
|
source_required=source_required,
|
||||||
|
report_required=report_required,
|
||||||
|
written_source_paths=written_source_paths,
|
||||||
|
written_report_paths=written_report_paths,
|
||||||
|
reports_written_after_execution=reports_written_after_execution,
|
||||||
|
verified_paths=verified_paths,
|
||||||
|
),
|
||||||
):
|
):
|
||||||
return (
|
return (
|
||||||
"未完成:工具迭代已用尽,但交付物没有形成完整的“写入后验证”证据链。"
|
"未完成:工具迭代已用尽,但交付物没有形成完整的“写入后验证”证据链。"
|
||||||
|
|||||||
+127
-1
@@ -9,6 +9,7 @@ from agent_platform.auth import UserIdentity
|
|||||||
from agent_platform.models import get_model_spec
|
from agent_platform.models import get_model_spec
|
||||||
from agent_platform.runtime.loop import (
|
from agent_platform.runtime.loop import (
|
||||||
AgentLoop,
|
AgentLoop,
|
||||||
|
deliverable_evidence_failure,
|
||||||
is_substantive_execution,
|
is_substantive_execution,
|
||||||
non_source_execution_error,
|
non_source_execution_error,
|
||||||
normalize_write_file_content,
|
normalize_write_file_content,
|
||||||
@@ -68,6 +69,31 @@ def test_python_cannot_execute_documentation_or_data_files() -> None:
|
|||||||
assert non_source_execution_error({"command": "python3 -c 'print(1)' "}) is None
|
assert non_source_execution_error({"command": "python3 -c 'print(1)' "}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_script_and_report_require_distinct_verified_artifacts() -> None:
|
||||||
|
assert (
|
||||||
|
deliverable_evidence_failure(
|
||||||
|
source_required=True,
|
||||||
|
report_required=True,
|
||||||
|
written_source_paths={"benchmark.py"},
|
||||||
|
written_report_paths=set(),
|
||||||
|
reports_written_after_execution=set(),
|
||||||
|
verified_paths={"benchmark.py"},
|
||||||
|
)
|
||||||
|
== "Create a separate report file such as `report.md`."
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
deliverable_evidence_failure(
|
||||||
|
source_required=True,
|
||||||
|
report_required=True,
|
||||||
|
written_source_paths={"benchmark.py"},
|
||||||
|
written_report_paths={"report.md"},
|
||||||
|
reports_written_after_execution={"report.md"},
|
||||||
|
verified_paths={"benchmark.py", "report.md"},
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_read_only_shell_commands_do_not_satisfy_execution_evidence() -> None:
|
def test_read_only_shell_commands_do_not_satisfy_execution_evidence() -> None:
|
||||||
assert not is_substantive_execution("exec", {"command": "cat report.txt"})
|
assert not is_substantive_execution("exec", {"command": "cat report.txt"})
|
||||||
assert not is_substantive_execution("exec", {"command": "sed -n '1,20p' script.py"})
|
assert not is_substantive_execution("exec", {"command": "sed -n '1,20p' script.py"})
|
||||||
@@ -355,6 +381,106 @@ async def test_artifact_completion_is_rejected_until_written_and_verified(settin
|
|||||||
await store.close()
|
await store.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_script_and_report_completion_requires_both_files(settings) -> None:
|
||||||
|
responses = [
|
||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"tool_calls": [
|
||||||
|
tool_call("1", "write_file", {"path": "benchmark.py", "content": "print('1 ms')\n"})
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"finish_reason": "tool_calls",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"tool_calls": [tool_call("2", "read_file", {"path": "benchmark.py"})],
|
||||||
|
},
|
||||||
|
"finish_reason": "tool_calls",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"tool_calls": [tool_call("3", "exec", {"command": "python3 benchmark.py"})],
|
||||||
|
},
|
||||||
|
"finish_reason": "tool_calls",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{"choices": [{"message": {"role": "assistant", "content": "完成"}, "finish_reason": "stop"}]},
|
||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"tool_calls": [
|
||||||
|
tool_call("4", "write_file", {"path": "report.md", "content": "# Report\n\n1 ms\n"})
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"finish_reason": "tool_calls",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"tool_calls": [tool_call("5", "read_file", {"path": "report.md"})],
|
||||||
|
},
|
||||||
|
"finish_reason": "tool_calls",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{"choices": [{"message": {"role": "assistant", "content": "已完成两个文件"}, "finish_reason": "stop"}]},
|
||||||
|
]
|
||||||
|
registry = RecordingRegistry()
|
||||||
|
store = RuntimeStore(settings.database_url)
|
||||||
|
await store.initialize()
|
||||||
|
events = []
|
||||||
|
|
||||||
|
async def callback(event_type, payload):
|
||||||
|
events.append(event_type)
|
||||||
|
|
||||||
|
try:
|
||||||
|
answer = await AgentLoop(
|
||||||
|
ScriptedProvider(responses),
|
||||||
|
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="script-report",
|
||||||
|
callback=callback,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await store.close()
|
||||||
|
|
||||||
|
assert answer == "已完成两个文件"
|
||||||
|
assert events.count("completion.rejected") == 1
|
||||||
|
assert [name for name, _ in registry.calls] == [
|
||||||
|
"write_file",
|
||||||
|
"read_file",
|
||||||
|
"exec",
|
||||||
|
"write_file",
|
||||||
|
"read_file",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class FailingExecutionRegistry(RecordingRegistry):
|
class FailingExecutionRegistry(RecordingRegistry):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -441,7 +567,7 @@ async def test_failed_execution_must_be_repaired_before_completion(settings) ->
|
|||||||
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",
|
||||||
|
|||||||
Reference in New Issue
Block a user