框架: 拆 dispatch_agent_task 为装配/执行/登记
This commit is contained in:
parent
041af960e7
commit
40bbf554a0
@ -218,7 +218,7 @@ events(会话)+ reviews(人审)+ revisions(修订 diff)
|
|||||||
执行状态(每完成一步在此打标,格式:`[x] YYYY-MM-DD <一句话证据>`;新会话从第一个未勾选项繼续):
|
执行状态(每完成一步在此打标,格式:`[x] YYYY-MM-DD <一句话证据>`;新会话从第一个未勾选项繼续):
|
||||||
|
|
||||||
- [x] P0.1 删 catalog 双投影 [x] 2026-08-28 git ls-files framework/catalog 为空;generate 写入 .pi/skills 与 .dsh/skills(各 15 个,gitignore);架构测试 12 项绿
|
- [x] P0.1 删 catalog 双投影 [x] 2026-08-28 git ls-files framework/catalog 为空;generate 写入 .pi/skills 与 .dsh/skills(各 15 个,gitignore);架构测试 12 项绿
|
||||||
- [ ] P0.2 拆 dispatch_agent_task
|
- [x] P0.2 拆 dispatch_agent_task [x] 2026-08-28 CLI 66 行;runtime 纯度门绿;dispatch 离线测试 37 项绿
|
||||||
- [ ] P0.3 建 data/muse.db(runs/events/reviews/revisions/cards)
|
- [ ] P0.3 建 data/muse.db(runs/events/reviews/revisions/cards)
|
||||||
- [ ] P0.4 存量迁移 PG → SQLite
|
- [ ] P0.4 存量迁移 PG → SQLite
|
||||||
- [ ] P1.4 蒸馏闭环(lesson 证据强制 + 人批准 + skill 升格)
|
- [ ] P1.4 蒸馏闭环(lesson 证据强制 + 人批准 + skill 升格)
|
||||||
|
|||||||
1
muse/flow/__init__.py
Normal file
1
muse/flow/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""写作链装配与编排。"""
|
||||||
680
muse/flow/dispatch.py
Normal file
680
muse/flow/dispatch.py
Normal file
@ -0,0 +1,680 @@
|
|||||||
|
"""把冻结任务包装配后派发给 Agent 执行器,并闭合 Muse 侧证据。
|
||||||
|
|
||||||
|
流程(全部失败关闭):
|
||||||
|
加载 spec -> 装配任务包 -> 登记 example_run -> 事件账本 run.started ->
|
||||||
|
runtime 执行(事件流逐条入账)-> 证据原子落库(raw + 逐回合 llm_call)
|
||||||
|
-> 结构化输出校验 -> run.completed + 运行终态。
|
||||||
|
|
||||||
|
业务决策(补证、重写、下一步)不属于本入口。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Iterable, Mapping
|
||||||
|
|
||||||
|
PROJECT_ROOT = next(
|
||||||
|
parent
|
||||||
|
for parent in (Path(__file__).resolve().parent, *Path(__file__).resolve().parents)
|
||||||
|
if (parent / "AGENTS.md").is_file() and (parent / ".git").exists()
|
||||||
|
)
|
||||||
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
DISPATCH_SCRIPTS = (
|
||||||
|
PROJECT_ROOT
|
||||||
|
/ "muse"
|
||||||
|
/ "lifecycle"
|
||||||
|
/ "dispatch"
|
||||||
|
/ "skills"
|
||||||
|
/ "dispatch-agent-task"
|
||||||
|
/ "scripts"
|
||||||
|
)
|
||||||
|
EVIDENCE_DIR = PROJECT_ROOT / "muse" / "authority" / "evidence" / "skills" / "record-run-evidence" / "scripts"
|
||||||
|
READ_TOOLS_DIR = PROJECT_ROOT / "muse" / "authority" / "tools" / "read"
|
||||||
|
for _path in (DISPATCH_SCRIPTS, EVIDENCE_DIR, READ_TOOLS_DIR):
|
||||||
|
if str(_path) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_path))
|
||||||
|
|
||||||
|
from role_task import ( # noqa: E402
|
||||||
|
OutputInvalidError,
|
||||||
|
TaskSpecError,
|
||||||
|
build_task_package,
|
||||||
|
load_spec,
|
||||||
|
validate_structured_output,
|
||||||
|
)
|
||||||
|
from agent_trace import AgentTraceWriter, persist_agent_evidence # noqa: E402
|
||||||
|
from persist_raw import _check_no_secrets # noqa: E402
|
||||||
|
import read_tools
|
||||||
|
from framework.adapters.pi.normalization import normalize_pi_transcript # noqa: E402
|
||||||
|
from framework.adapters.pi.runner import ( # noqa: E402
|
||||||
|
AgentStreamOutcome,
|
||||||
|
ExecutionPolicy,
|
||||||
|
FrameworkError,
|
||||||
|
)
|
||||||
|
from role_policy import validate_role_execution_policy # noqa: E402
|
||||||
|
from run_registry import finish_run, new_run_id, start_run # noqa: E402
|
||||||
|
from runtime.agent_executor import execute_agent # noqa: E402
|
||||||
|
from runtime.runs import ( # noqa: E402
|
||||||
|
DEFAULT_RUN_DIR_ROOT,
|
||||||
|
open_transcript,
|
||||||
|
prepare_run_dir,
|
||||||
|
read_transcript_text,
|
||||||
|
remove_local_raw,
|
||||||
|
seed_run_inputs,
|
||||||
|
validate_run_id,
|
||||||
|
write_private_json,
|
||||||
|
write_private_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
EXIT_OK = 0
|
||||||
|
EXIT_SPEC_INVALID = 2
|
||||||
|
EXIT_FRAMEWORK_FAILED = 3
|
||||||
|
EXIT_OUTPUT_INVALID = 4
|
||||||
|
EXIT_EVIDENCE_FAILED = 5
|
||||||
|
|
||||||
|
READ_TOOL_EXTENSION = PROJECT_ROOT / "framework" / "adapters" / "pi" / "mcp_bridge.ts"
|
||||||
|
BUILTIN_TOOL_NAMES = frozenset({"read", "grep", "find", "ls"})
|
||||||
|
|
||||||
|
|
||||||
|
def _usage_int(usage: Mapping[str, Any], *keys: str) -> int:
|
||||||
|
"""读取第一种存在的 usage 字段;脏值与负值按 0 聚合。"""
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
if key not in usage:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return max(0, int(usage.get(key) or 0))
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
return 0
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _usage_totals(outcome: AgentStreamOutcome) -> dict[str, int]:
|
||||||
|
"""聚合全部模型回合的 token 用量;输入口径包含 cache 读写。"""
|
||||||
|
|
||||||
|
totals = {"inputTokens": 0, "outputTokens": 0, "cachedTokens": 0, "reasoningTokens": 0}
|
||||||
|
for call in outcome.model_calls:
|
||||||
|
usage = call.usage or {}
|
||||||
|
cached = _usage_int(usage, "cacheRead", "cache_read_input_tokens", "cached_tokens")
|
||||||
|
cache_write = _usage_int(usage, "cacheWrite", "cache_creation_input_tokens")
|
||||||
|
totals["inputTokens"] += _usage_int(usage, "input", "input_tokens", "prompt_tokens") + cached + cache_write
|
||||||
|
totals["outputTokens"] += _usage_int(usage, "output", "output_tokens", "completion_tokens")
|
||||||
|
totals["cachedTokens"] += cached
|
||||||
|
totals["reasoningTokens"] += _usage_int(usage, "reasoning", "reasoning_tokens")
|
||||||
|
return totals
|
||||||
|
|
||||||
|
|
||||||
|
def _cost_totals(outcome: AgentStreamOutcome) -> tuple[float | None, bool]:
|
||||||
|
"""已知成本求和;任一回合未知则 total 为 None 并标记不完整。"""
|
||||||
|
|
||||||
|
known: list[float] = []
|
||||||
|
complete = True
|
||||||
|
for call in outcome.model_calls:
|
||||||
|
if call.cost_usd is None:
|
||||||
|
complete = False
|
||||||
|
else:
|
||||||
|
known.append(call.cost_usd)
|
||||||
|
if not complete:
|
||||||
|
return (sum(known) if known else None), False
|
||||||
|
return sum(known), True
|
||||||
|
|
||||||
|
|
||||||
|
def _model_call_rows(outcome: AgentStreamOutcome) -> list[dict[str, Any]]:
|
||||||
|
"""把模型回合映射成 agent_trace.persist_agent_evidence 的输入行。"""
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"actual_model_id": call.actual_model_id,
|
||||||
|
"usage": dict(call.usage or {}),
|
||||||
|
"stop_reason": call.stop_reason,
|
||||||
|
"cost_usd": call.cost_usd,
|
||||||
|
"duration_ms": None,
|
||||||
|
}
|
||||||
|
for call in outcome.model_calls
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run_dispatch(
|
||||||
|
spec_path: str | Path,
|
||||||
|
*,
|
||||||
|
repo_root: str | Path,
|
||||||
|
policy: ExecutionPolicy,
|
||||||
|
run_id: str | None = None,
|
||||||
|
run_dir: str | Path | None = None,
|
||||||
|
connect_factory: Callable[..., Any] | None = None,
|
||||||
|
launcher: Callable[..., Iterable] | None = None,
|
||||||
|
trigger_source: str = "user",
|
||||||
|
trigger_detail: Mapping[str, Any] | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
session_dir: str | Path | None = None,
|
||||||
|
enable_read_tools: bool = False,
|
||||||
|
) -> tuple[dict[str, Any], int]:
|
||||||
|
"""执行一次完整派发;所有可控失败都返回稳定回执与退出码。"""
|
||||||
|
|
||||||
|
repo_root_path = Path(repo_root).resolve()
|
||||||
|
try:
|
||||||
|
spec = load_spec(spec_path)
|
||||||
|
package = build_task_package(spec, repo_root_path)
|
||||||
|
_check_no_secrets(package.system_prompt)
|
||||||
|
_check_no_secrets(package.user_message)
|
||||||
|
except TaskSpecError as exc:
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"errorCode": "SPEC_INVALID",
|
||||||
|
"error": str(exc),
|
||||||
|
"specPath": str(spec_path),
|
||||||
|
},
|
||||||
|
EXIT_SPEC_INVALID,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"errorCode": "SPEC_INVALID",
|
||||||
|
"error": f"任务包不可安全执行: {type(exc).__name__}",
|
||||||
|
"specPath": str(spec_path),
|
||||||
|
},
|
||||||
|
EXIT_SPEC_INVALID,
|
||||||
|
)
|
||||||
|
|
||||||
|
disabled_read_tools = sorted(
|
||||||
|
{
|
||||||
|
tool
|
||||||
|
for tool in package.spec.tool_allowlist
|
||||||
|
if tool in read_tools.TOOL_REGISTRY and not enable_read_tools
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if disabled_read_tools:
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"errorCode": "READ_TOOLS_NOT_ENABLED",
|
||||||
|
"error": f"任务包含探索工具但未启用工具 server: {disabled_read_tools}",
|
||||||
|
},
|
||||||
|
EXIT_SPEC_INVALID,
|
||||||
|
)
|
||||||
|
policy_kwargs: dict[str, Any] = {"cwd": policy.cwd or str(repo_root_path)}
|
||||||
|
if session_id is not None:
|
||||||
|
policy_kwargs["session_id"] = session_id
|
||||||
|
policy_kwargs["session_dir"] = str(session_dir) if session_dir is not None else None
|
||||||
|
if enable_read_tools:
|
||||||
|
# 工具 server 扩展经环境变量定位只读实现;框架进程继承该环境。
|
||||||
|
os.environ["MUSE_READ_TOOLS_PYTHON"] = sys.executable
|
||||||
|
os.environ["MUSE_READ_TOOLS_SCRIPT"] = str(
|
||||||
|
READ_TOOLS_DIR / "read_tools.py"
|
||||||
|
)
|
||||||
|
policy_kwargs["extension_path"] = str(READ_TOOL_EXTENSION)
|
||||||
|
effective_policy = replace(policy, **policy_kwargs)
|
||||||
|
try:
|
||||||
|
validate_role_execution_policy(package, effective_policy)
|
||||||
|
except ValueError as exc:
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"errorCode": "ROLE_MODEL_POLICY_MISMATCH",
|
||||||
|
"error": str(exc),
|
||||||
|
"requestedModelId": effective_policy.requested_model_id,
|
||||||
|
},
|
||||||
|
EXIT_SPEC_INVALID,
|
||||||
|
)
|
||||||
|
run_id = run_id or new_run_id(
|
||||||
|
f"agent-{spec.role}", work_id=spec.work_id, target_chapter=spec.target_chapter
|
||||||
|
)
|
||||||
|
if not validate_run_id(run_id):
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"errorCode": "RUN_ID_INVALID",
|
||||||
|
"error": "run_id 只能包含 ASCII 字母、数字、点、下划线和短横线,长度不超过 64",
|
||||||
|
},
|
||||||
|
EXIT_SPEC_INVALID,
|
||||||
|
)
|
||||||
|
run_dir_path = Path(run_dir) if run_dir is not None else DEFAULT_RUN_DIR_ROOT / run_id
|
||||||
|
identity = package.as_identity()
|
||||||
|
|
||||||
|
def _receipt(**fields: Any) -> dict[str, Any]:
|
||||||
|
base = {
|
||||||
|
"runId": run_id,
|
||||||
|
"role": spec.role,
|
||||||
|
"framework": effective_policy.framework,
|
||||||
|
"requestedModelId": effective_policy.requested_model_id,
|
||||||
|
"outputSchemaId": spec.output_schema_id,
|
||||||
|
"runDir": str(run_dir_path),
|
||||||
|
**identity,
|
||||||
|
}
|
||||||
|
base.update(fields)
|
||||||
|
return base
|
||||||
|
|
||||||
|
try:
|
||||||
|
run_dir_path = prepare_run_dir(run_id, run_dir)
|
||||||
|
seed_run_inputs(
|
||||||
|
run_dir_path,
|
||||||
|
spec_text=Path(spec_path).read_text(encoding="utf-8"),
|
||||||
|
system_prompt=package.system_prompt,
|
||||||
|
user_message=package.user_message,
|
||||||
|
)
|
||||||
|
except (OSError, UnicodeError) as exc:
|
||||||
|
return (
|
||||||
|
_receipt(
|
||||||
|
status="failed",
|
||||||
|
errorCode="AUDIT_WRITE_FAILED",
|
||||||
|
error=f"运行审计目录写入失败: {type(exc).__name__}",
|
||||||
|
),
|
||||||
|
EXIT_EVIDENCE_FAILED,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
detail = dict(trigger_detail or {})
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
receipt = _receipt(
|
||||||
|
status="failed",
|
||||||
|
errorCode="TRIGGER_DETAIL_INVALID",
|
||||||
|
error=f"trigger_detail 不是对象: {type(exc).__name__}",
|
||||||
|
)
|
||||||
|
write_private_json(run_dir_path / "receipt.json", receipt)
|
||||||
|
return receipt, EXIT_SPEC_INVALID
|
||||||
|
detail.setdefault(
|
||||||
|
"dispatch",
|
||||||
|
{
|
||||||
|
"framework": effective_policy.framework,
|
||||||
|
"requestedModelId": effective_policy.requested_model_id,
|
||||||
|
"specSha256": package.spec_sha256,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
detail_json = json.dumps(
|
||||||
|
detail, ensure_ascii=False, sort_keys=True, default=str, allow_nan=False
|
||||||
|
)
|
||||||
|
_check_no_secrets(detail_json)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
receipt = _receipt(
|
||||||
|
status="failed",
|
||||||
|
errorCode="TRIGGER_DETAIL_INVALID",
|
||||||
|
error=f"trigger_detail 不可安全记录: {type(exc).__name__}",
|
||||||
|
)
|
||||||
|
write_private_json(run_dir_path / "receipt.json", receipt)
|
||||||
|
return receipt, EXIT_SPEC_INVALID
|
||||||
|
try:
|
||||||
|
run_record = start_run(
|
||||||
|
connect=connect_factory,
|
||||||
|
run_id=run_id,
|
||||||
|
work_id=spec.work_id,
|
||||||
|
target_chapter=spec.target_chapter,
|
||||||
|
trigger_source=trigger_source,
|
||||||
|
trigger_detail=detail,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - 注册失败不能启动外部 Agent。
|
||||||
|
receipt = _receipt(
|
||||||
|
status="failed",
|
||||||
|
errorCode="RUN_REGISTRY_START_FAILED",
|
||||||
|
error=f"运行登记失败: {type(exc).__name__}",
|
||||||
|
)
|
||||||
|
write_private_json(run_dir_path / "receipt.json", receipt)
|
||||||
|
return receipt, EXIT_EVIDENCE_FAILED
|
||||||
|
if run_record["status"] != "started":
|
||||||
|
receipt = _receipt(
|
||||||
|
status="failed",
|
||||||
|
errorCode="RUN_ID_EXISTS",
|
||||||
|
error="run_id 已存在,拒绝覆盖既有运行证据",
|
||||||
|
)
|
||||||
|
write_private_json(run_dir_path / "receipt.json", receipt)
|
||||||
|
return receipt, EXIT_EVIDENCE_FAILED
|
||||||
|
|
||||||
|
writer = AgentTraceWriter(
|
||||||
|
run_id=run_id,
|
||||||
|
framework=effective_policy.framework,
|
||||||
|
agent_role=spec.role,
|
||||||
|
connect=connect_factory,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _failed(
|
||||||
|
error_code: str,
|
||||||
|
error: str,
|
||||||
|
exit_code: int,
|
||||||
|
*,
|
||||||
|
agent_failed: bool = False,
|
||||||
|
session_id: str | None = None,
|
||||||
|
final_message: str | None = None,
|
||||||
|
evidence: Mapping[str, Any] | None = None,
|
||||||
|
cause_error_code: str | None = None,
|
||||||
|
) -> tuple[dict[str, Any], int]:
|
||||||
|
"""尽力闭合失败终态;留痕本身失败时升级为证据错误,保留原始原因码。"""
|
||||||
|
|
||||||
|
failures: list[str] = []
|
||||||
|
if final_message is not None:
|
||||||
|
try:
|
||||||
|
write_private_text(run_dir_path / "final-message.txt", final_message)
|
||||||
|
except OSError as exc:
|
||||||
|
failures.append(type(exc).__name__)
|
||||||
|
if agent_failed:
|
||||||
|
try:
|
||||||
|
writer.emit(
|
||||||
|
"agent.failed",
|
||||||
|
status="error",
|
||||||
|
requested_model_id=effective_policy.requested_model_id,
|
||||||
|
details={"errorCode": error_code},
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - 继续尝试写 run.failed/终态。
|
||||||
|
failures.append(type(exc).__name__)
|
||||||
|
try:
|
||||||
|
writer.emit(
|
||||||
|
"run.failed",
|
||||||
|
status="error",
|
||||||
|
requested_model_id=effective_policy.requested_model_id,
|
||||||
|
raw_ref=(evidence or {}).get("transcriptId"),
|
||||||
|
details={"errorCode": error_code},
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - 继续尝试闭合 example_run。
|
||||||
|
failures.append(type(exc).__name__)
|
||||||
|
try:
|
||||||
|
finish_run(
|
||||||
|
run_id,
|
||||||
|
"failed",
|
||||||
|
trigger_detail={"errorCode": error_code},
|
||||||
|
connect=connect_factory,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - 回执必须揭示终态未能闭合。
|
||||||
|
failures.append(type(exc).__name__)
|
||||||
|
|
||||||
|
fields: dict[str, Any] = {
|
||||||
|
"status": "failed",
|
||||||
|
"errorCode": error_code,
|
||||||
|
"error": error,
|
||||||
|
"sessionId": session_id,
|
||||||
|
}
|
||||||
|
if evidence is not None:
|
||||||
|
fields["evidence"] = dict(evidence)
|
||||||
|
if cause_error_code is not None:
|
||||||
|
fields["causeErrorCode"] = cause_error_code
|
||||||
|
if failures:
|
||||||
|
fields.update(
|
||||||
|
{
|
||||||
|
"causeErrorCode": cause_error_code or error_code,
|
||||||
|
"errorCode": "EVIDENCE_PERSIST_FAILED",
|
||||||
|
"error": "失败终态留痕未完整写入",
|
||||||
|
"finalizationErrorTypes": sorted(set(failures)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
exit_code = EXIT_EVIDENCE_FAILED
|
||||||
|
receipt = _receipt(**fields)
|
||||||
|
try:
|
||||||
|
write_private_json(run_dir_path / "receipt.json", receipt)
|
||||||
|
except OSError:
|
||||||
|
receipt["receiptFileWritten"] = False
|
||||||
|
exit_code = EXIT_EVIDENCE_FAILED
|
||||||
|
return receipt, exit_code
|
||||||
|
|
||||||
|
try:
|
||||||
|
writer.emit(
|
||||||
|
"run.started",
|
||||||
|
status="ok",
|
||||||
|
requested_model_id=effective_policy.requested_model_id,
|
||||||
|
details={"specSha256": package.spec_sha256, "toolAllowlist": list(spec.tool_allowlist)},
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - 未留起始事件时不得启动框架。
|
||||||
|
return _failed(
|
||||||
|
"EVIDENCE_PERSIST_FAILED",
|
||||||
|
f"运行起始事件写入失败: {type(exc).__name__}",
|
||||||
|
EXIT_EVIDENCE_FAILED,
|
||||||
|
)
|
||||||
|
|
||||||
|
transcript_path = run_dir_path / "transcript.jsonl"
|
||||||
|
|
||||||
|
def _persist_failure_evidence(
|
||||||
|
failed_outcome: AgentStreamOutcome | None,
|
||||||
|
) -> tuple[dict[str, Any] | None, str | None]:
|
||||||
|
"""失败也尽量把已有转录和模型回合写入同一套 raw 证据。"""
|
||||||
|
|
||||||
|
if failed_outcome is None:
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
transcript_text = read_transcript_text(run_dir_path)
|
||||||
|
except (OSError, UnicodeError):
|
||||||
|
return None, "EVIDENCE_PERSIST_FAILED"
|
||||||
|
if not transcript_text.strip():
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
_check_no_secrets(transcript_text)
|
||||||
|
except ValueError:
|
||||||
|
remove_local_raw(run_dir_path)
|
||||||
|
return None, "RAW_SECRET_DETECTED"
|
||||||
|
try:
|
||||||
|
evidence_result = persist_agent_evidence(
|
||||||
|
run_id=run_id,
|
||||||
|
agent_role=spec.role,
|
||||||
|
system_prompt=package.system_prompt,
|
||||||
|
user_message=package.user_message,
|
||||||
|
final_message=failed_outcome.final_text,
|
||||||
|
transcript=transcript_text,
|
||||||
|
model_calls=_model_call_rows(failed_outcome),
|
||||||
|
requested_model_id=effective_policy.requested_model_id,
|
||||||
|
connect=connect_factory,
|
||||||
|
)
|
||||||
|
return evidence_result, None
|
||||||
|
except Exception:
|
||||||
|
return None, "EVIDENCE_PERSIST_FAILED"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open_transcript(run_dir_path) as transcript_file:
|
||||||
|
outcome = execute_agent(
|
||||||
|
package.as_framework_request(
|
||||||
|
session_mode="continue" if session_id is not None else "fresh",
|
||||||
|
request_id=run_id,
|
||||||
|
),
|
||||||
|
effective_policy,
|
||||||
|
writer,
|
||||||
|
timeout_seconds=spec.max_duration_seconds,
|
||||||
|
raw_sink=transcript_file.write,
|
||||||
|
launcher=launcher,
|
||||||
|
)
|
||||||
|
except FrameworkError as exc:
|
||||||
|
failed_outcome = exc.outcome
|
||||||
|
failure_evidence, evidence_error = _persist_failure_evidence(failed_outcome)
|
||||||
|
if evidence_error is not None:
|
||||||
|
return _failed(
|
||||||
|
evidence_error,
|
||||||
|
"框架失败证据未能安全落库",
|
||||||
|
EXIT_EVIDENCE_FAILED,
|
||||||
|
agent_failed=True,
|
||||||
|
session_id=failed_outcome.session_id if failed_outcome else None,
|
||||||
|
cause_error_code=exc.error_code,
|
||||||
|
)
|
||||||
|
return _failed(
|
||||||
|
exc.error_code,
|
||||||
|
str(exc),
|
||||||
|
EXIT_FRAMEWORK_FAILED,
|
||||||
|
agent_failed=True,
|
||||||
|
session_id=failed_outcome.session_id if failed_outcome else None,
|
||||||
|
final_message=failed_outcome.final_text if failed_outcome else None,
|
||||||
|
evidence=failure_evidence,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - 事件/本地转录失败属于证据失败。
|
||||||
|
return _failed(
|
||||||
|
"EVIDENCE_PERSIST_FAILED",
|
||||||
|
f"框架执行留痕失败: {type(exc).__name__}",
|
||||||
|
EXIT_EVIDENCE_FAILED,
|
||||||
|
agent_failed=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
transcript_text = read_transcript_text(run_dir_path)
|
||||||
|
_check_no_secrets(transcript_text)
|
||||||
|
except ValueError as exc:
|
||||||
|
remove_local_raw(run_dir_path)
|
||||||
|
return _failed(
|
||||||
|
"RAW_SECRET_DETECTED",
|
||||||
|
"框架转录含疑似凭据,已拒绝留存",
|
||||||
|
EXIT_EVIDENCE_FAILED,
|
||||||
|
)
|
||||||
|
except (OSError, UnicodeError) as exc:
|
||||||
|
return _failed(
|
||||||
|
"EVIDENCE_PERSIST_FAILED",
|
||||||
|
f"框架转录回读失败: {type(exc).__name__}",
|
||||||
|
EXIT_EVIDENCE_FAILED,
|
||||||
|
session_id=outcome.session_id,
|
||||||
|
)
|
||||||
|
if not transcript_text.strip():
|
||||||
|
return _failed(
|
||||||
|
"TRANSCRIPT_EMPTY",
|
||||||
|
"框架转录为空",
|
||||||
|
EXIT_FRAMEWORK_FAILED,
|
||||||
|
session_id=outcome.session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
framework_artifact = normalize_pi_transcript(
|
||||||
|
transcript_path,
|
||||||
|
run_dir_path / "framework-events.jsonl",
|
||||||
|
run_id=run_id,
|
||||||
|
framework_version="pi-json-v1",
|
||||||
|
)
|
||||||
|
except (OSError, UnicodeError, ValueError) as exc:
|
||||||
|
return _failed(
|
||||||
|
"FRAMEWORK_ARTIFACT_INVALID",
|
||||||
|
f"通用框架事件工件生成失败: {type(exc).__name__}",
|
||||||
|
EXIT_EVIDENCE_FAILED,
|
||||||
|
session_id=outcome.session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
evidence = persist_agent_evidence(
|
||||||
|
run_id=run_id,
|
||||||
|
agent_role=spec.role,
|
||||||
|
system_prompt=package.system_prompt,
|
||||||
|
user_message=package.user_message,
|
||||||
|
final_message=outcome.final_text,
|
||||||
|
transcript=transcript_text,
|
||||||
|
model_calls=_model_call_rows(outcome),
|
||||||
|
requested_model_id=effective_policy.requested_model_id,
|
||||||
|
connect=connect_factory,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - 模型成功但证据失败时必须失败关闭。
|
||||||
|
return _failed(
|
||||||
|
"EVIDENCE_PERSIST_FAILED",
|
||||||
|
f"框架证据落库失败: {type(exc).__name__}",
|
||||||
|
EXIT_EVIDENCE_FAILED,
|
||||||
|
session_id=outcome.session_id,
|
||||||
|
final_message=outcome.final_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
structured = validate_structured_output(outcome.final_text or "", spec)
|
||||||
|
except OutputInvalidError as exc:
|
||||||
|
return _failed(
|
||||||
|
"OUTPUT_SCHEMA_INVALID",
|
||||||
|
str(exc),
|
||||||
|
EXIT_OUTPUT_INVALID,
|
||||||
|
session_id=outcome.session_id,
|
||||||
|
final_message=outcome.final_text,
|
||||||
|
evidence=evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
write_private_text(run_dir_path / "final-message.txt", outcome.final_text or "")
|
||||||
|
write_private_json(run_dir_path / "output.json", structured)
|
||||||
|
except OSError as exc:
|
||||||
|
return _failed(
|
||||||
|
"AUDIT_WRITE_FAILED",
|
||||||
|
f"运行结果审计文件写入失败: {type(exc).__name__}",
|
||||||
|
EXIT_EVIDENCE_FAILED,
|
||||||
|
session_id=outcome.session_id,
|
||||||
|
evidence=evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 依赖清单:本次运行实际读取了什么(工具调用序列与参数),链路透视的读侧材料。
|
||||||
|
dependency_count = len(outcome.tool_calls)
|
||||||
|
if dependency_count:
|
||||||
|
dependencies = [
|
||||||
|
{
|
||||||
|
"seq": index + 1,
|
||||||
|
"tool": call.name,
|
||||||
|
"args": dict(call.args) if call.args else {},
|
||||||
|
"isError": call.is_error,
|
||||||
|
}
|
||||||
|
for index, call in enumerate(outcome.tool_calls)
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
write_private_json(run_dir_path / "dependencies.json", dependencies)
|
||||||
|
except OSError as exc:
|
||||||
|
return _failed(
|
||||||
|
"AUDIT_WRITE_FAILED",
|
||||||
|
f"依赖清单写入失败: {type(exc).__name__}",
|
||||||
|
EXIT_EVIDENCE_FAILED,
|
||||||
|
session_id=outcome.session_id,
|
||||||
|
evidence=evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
usage = _usage_totals(outcome)
|
||||||
|
total_cost, cost_complete = _cost_totals(outcome)
|
||||||
|
try:
|
||||||
|
writer.emit(
|
||||||
|
"run.completed",
|
||||||
|
status="ok",
|
||||||
|
requested_model_id=effective_policy.requested_model_id,
|
||||||
|
actual_model_id=outcome.model_calls[-1].actual_model_id,
|
||||||
|
usage={
|
||||||
|
"input": usage["inputTokens"] - usage["cachedTokens"],
|
||||||
|
"output": usage["outputTokens"],
|
||||||
|
"cacheRead": usage["cachedTokens"],
|
||||||
|
},
|
||||||
|
cost_usd=total_cost,
|
||||||
|
raw_ref=evidence.get("transcriptId"),
|
||||||
|
details={
|
||||||
|
"sessionId": outcome.session_id,
|
||||||
|
"turns": outcome.turns,
|
||||||
|
"toolCalls": len(outcome.tool_calls),
|
||||||
|
"unknownFrameworkEvents": list(outcome.unknown_event_types),
|
||||||
|
"costComplete": cost_complete,
|
||||||
|
"leaseId": evidence.get("leaseId"),
|
||||||
|
"llmCallIds": evidence.get("llmCallIds"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
finish_run(run_id, "completed", connect=connect_factory)
|
||||||
|
except Exception as exc: # noqa: BLE001 - 成功终态与终态事件必须一起可见。
|
||||||
|
return _failed(
|
||||||
|
"RUN_FINALIZE_FAILED",
|
||||||
|
f"成功终态写入失败: {type(exc).__name__}",
|
||||||
|
EXIT_EVIDENCE_FAILED,
|
||||||
|
session_id=outcome.session_id,
|
||||||
|
evidence=evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
receipt = _receipt(
|
||||||
|
status="completed",
|
||||||
|
sessionId=outcome.session_id,
|
||||||
|
durationMs=outcome.duration_ms,
|
||||||
|
turns=outcome.turns,
|
||||||
|
toolCallCount=len(outcome.tool_calls),
|
||||||
|
unknownFrameworkEvents=list(outcome.unknown_event_types),
|
||||||
|
dependencies=(
|
||||||
|
{"count": dependency_count, "file": "dependencies.json"}
|
||||||
|
if dependency_count
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
modelCallCount=len(outcome.model_calls),
|
||||||
|
actualModelIds=[call.actual_model_id for call in outcome.model_calls],
|
||||||
|
usage=usage,
|
||||||
|
totalCostUsd=round(total_cost, 6) if total_cost is not None else None,
|
||||||
|
costComplete=cost_complete,
|
||||||
|
finalMessageSha256=_sha256_bare(outcome.final_text or ""),
|
||||||
|
structuredOutputSha256=_sha256_bare(
|
||||||
|
json.dumps(structured, ensure_ascii=False, sort_keys=True)
|
||||||
|
),
|
||||||
|
evidence=evidence,
|
||||||
|
frameworkArtifact=framework_artifact,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
write_private_json(run_dir_path / "receipt.json", receipt)
|
||||||
|
except OSError:
|
||||||
|
receipt["receiptFileWritten"] = False
|
||||||
|
return receipt, EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_bare(text: str) -> str:
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
@ -20,12 +20,15 @@ disable-model-invocation: true
|
|||||||
| 模块 | 职责 |
|
| 模块 | 职责 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `muse/lifecycle/dispatch/skills/dispatch-agent-task/scripts/role_task.py` | Muse 侧任务包装配:spec、角色合同、业务 Schema 和模型策略前置校验。 |
|
| `muse/lifecycle/dispatch/skills/dispatch-agent-task/scripts/role_task.py` | Muse 侧任务包装配:spec、角色合同、业务 Schema 和模型策略前置校验。 |
|
||||||
|
| `muse/flow/dispatch.py` | 装配与编排:任务包、角色模型策略、证据账本、结构化输出校验;调用 runtime 执行。 |
|
||||||
|
| `runtime/agent_executor.py` | 多轮 Agent 调用;不加载 Muse 合同。 |
|
||||||
|
| `runtime/runs.py` | 不可变 run 目录读写;不加载 Muse 合同。 |
|
||||||
| `framework/primitives/execution.py` | 框架通用执行请求、事件和结果对象;不加载 Muse 合同。 |
|
| `framework/primitives/execution.py` | 框架通用执行请求、事件和结果对象;不加载 Muse 合同。 |
|
||||||
| `framework/adapters/pi/runner.py` | Pi 框架适配器:只消费 `FrameworkExecutionRequest`,构造 argv、消费 JSON 事件流并执行超时保护;当前生产入口。 |
|
| `framework/adapters/pi/runner.py` | Pi 框架适配器:只消费 `FrameworkExecutionRequest`,构造 argv、消费 JSON 事件流并执行超时保护;当前生产入口。 |
|
||||||
| `framework/adapters/dsh/` | DSH headless 对照适配器:只开放无工具 fresh 任务,读取 flush 后 session JSONL;未接入本 Skill 的生产派发。 |
|
| `framework/adapters/dsh/` | DSH headless 对照适配器:只开放无工具 fresh 任务,读取 flush 后 session JSONL;未接入本 Skill 的生产派发。 |
|
||||||
| `muse/lifecycle/dispatch/skills/dispatch-agent-task/scripts/read_tools.py` | 探索工具 server 只读实现:五个登记工具(细纲/文风/范式绑定/章节正文/实体检索),只读连接、未知工具拒绝、结果有界;`--list` 输出登记表。 |
|
| `muse/lifecycle/dispatch/skills/dispatch-agent-task/scripts/read_tools.py` | 探索工具 server 只读实现:五个登记工具(细纲/文风/范式绑定/章节正文/实体检索),只读连接、未知工具拒绝、结果有界;`--list` 输出登记表。 |
|
||||||
| `framework/adapters/pi/mcp_bridge.ts` | 工具 server 的框架扩展:装载时从 `read_tools.py --list` 动态注册工具,每次执行转给只读实现;不经派发器注入环境变量时不注册任何工具。 |
|
| `framework/adapters/pi/mcp_bridge.ts` | 工具 server 的框架扩展:装载时从 `read_tools.py --list` 动态注册工具,每次执行转给只读实现;不经派发器注入环境变量时不注册任何工具。 |
|
||||||
| `muse/lifecycle/dispatch/skills/dispatch-agent-task/scripts/dispatch_agent_task.py` | CLI:运行登记 -> 事件入账 -> 框架执行 -> 校验 -> 证据落库 -> 依赖清单 -> 回执。 |
|
| `muse/lifecycle/dispatch/skills/dispatch-agent-task/scripts/dispatch_agent_task.py` | CLI 薄入口:解析参数后调用 `muse.flow.dispatch.run_dispatch`。 |
|
||||||
|
|
||||||
## 输入与输出
|
## 输入与输出
|
||||||
|
|
||||||
|
|||||||
@ -1,708 +1,31 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""dispatch-agent-task CLI:把冻结任务包派发给 Agent 框架子代理并自动留痕。
|
"""dispatch-agent-task CLI:装配见 muse.flow.dispatch,执行见 runtime。"""
|
||||||
|
|
||||||
流程(全部失败关闭):
|
|
||||||
加载 spec -> 装配任务包 -> 登记 example_run -> 事件账本 run.started ->
|
|
||||||
框架适配器执行(事件流逐条入账)-> 证据原子落库(raw + 逐回合 llm_call)
|
|
||||||
-> 结构化输出校验 -> run.completed + 运行终态 -> 打印回执。
|
|
||||||
|
|
||||||
业务决策(补证、重写、下一步)不属于本入口:那是框架里模型的事。
|
|
||||||
用法:
|
|
||||||
.venv/bin/python dispatch_agent_task.py --spec task.json \
|
|
||||||
[--provider P] [--model M] [--thinking low] [--run-id ID]
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import replace
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Iterable, Mapping
|
|
||||||
|
|
||||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
||||||
PROJECT_ROOT = next(
|
PROJECT_ROOT = next(
|
||||||
parent
|
parent
|
||||||
for parent in (SCRIPT_DIR, *SCRIPT_DIR.parents)
|
for parent in (Path(__file__).resolve().parent, *Path(__file__).resolve().parents)
|
||||||
if (parent / "AGENTS.md").is_file() and (parent / ".git").exists()
|
if (parent / "AGENTS.md").is_file() and (parent / ".git").exists()
|
||||||
)
|
)
|
||||||
if str(PROJECT_ROOT) not in sys.path:
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
if str(SCRIPT_DIR) not in sys.path:
|
|
||||||
sys.path.insert(0, str(SCRIPT_DIR))
|
|
||||||
EVIDENCE_DIR = PROJECT_ROOT / "muse" / "authority" / "evidence" / "skills" / "record-run-evidence" / "scripts"
|
|
||||||
READ_TOOLS_DIR = PROJECT_ROOT / "muse" / "authority" / "tools" / "read"
|
|
||||||
for _path in (EVIDENCE_DIR, READ_TOOLS_DIR):
|
|
||||||
if str(_path) not in sys.path:
|
|
||||||
sys.path.insert(0, str(_path))
|
|
||||||
|
|
||||||
from role_task import ( # noqa: E402
|
from framework.adapters.pi.runner import DEFAULT_PI_BIN, ExecutionPolicy # noqa: E402
|
||||||
AgentTaskSpec,
|
from muse.flow.dispatch import ( # noqa: E402
|
||||||
OutputInvalidError,
|
DEFAULT_RUN_DIR_ROOT,
|
||||||
TaskSpecError,
|
EXIT_EVIDENCE_FAILED,
|
||||||
build_task_package,
|
EXIT_FRAMEWORK_FAILED,
|
||||||
load_spec,
|
EXIT_OK,
|
||||||
validate_structured_output,
|
EXIT_OUTPUT_INVALID,
|
||||||
|
EXIT_SPEC_INVALID,
|
||||||
|
run_dispatch,
|
||||||
)
|
)
|
||||||
from agent_trace import AgentTraceWriter, persist_agent_evidence # noqa: E402
|
|
||||||
from persist_raw import _check_no_secrets # noqa: E402
|
|
||||||
import read_tools
|
|
||||||
from framework.adapters.pi.normalization import normalize_pi_transcript # noqa: E402
|
|
||||||
from framework.adapters.pi.runner import ( # noqa: E402
|
|
||||||
DEFAULT_PI_BIN,
|
|
||||||
AgentStreamOutcome,
|
|
||||||
ExecutionPolicy,
|
|
||||||
FrameworkError,
|
|
||||||
PiAgentRunner,
|
|
||||||
)
|
|
||||||
from role_policy import validate_role_execution_policy # noqa: E402
|
|
||||||
from run_registry import finish_run, new_run_id, start_run # noqa: E402
|
|
||||||
|
|
||||||
EXIT_OK = 0
|
|
||||||
EXIT_SPEC_INVALID = 2
|
|
||||||
EXIT_FRAMEWORK_FAILED = 3
|
|
||||||
EXIT_OUTPUT_INVALID = 4
|
|
||||||
EXIT_EVIDENCE_FAILED = 5
|
|
||||||
|
|
||||||
DEFAULT_RUN_DIR_ROOT = Path("/tmp/muse-agent-runs")
|
|
||||||
_RUN_ID_PATTERN = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
|
||||||
|
|
||||||
|
|
||||||
def _usage_int(usage: Mapping[str, Any], *keys: str) -> int:
|
|
||||||
"""读取第一种存在的 usage 字段;脏值与负值按 0 聚合。"""
|
|
||||||
|
|
||||||
for key in keys:
|
|
||||||
if key not in usage:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
return max(0, int(usage.get(key) or 0))
|
|
||||||
except (TypeError, ValueError, OverflowError):
|
|
||||||
return 0
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def _usage_totals(outcome: AgentStreamOutcome) -> dict[str, int]:
|
|
||||||
"""聚合全部模型回合的 token 用量;输入口径包含 cache 读写。"""
|
|
||||||
|
|
||||||
totals = {"inputTokens": 0, "outputTokens": 0, "cachedTokens": 0, "reasoningTokens": 0}
|
|
||||||
for call in outcome.model_calls:
|
|
||||||
usage = call.usage or {}
|
|
||||||
cached = _usage_int(usage, "cacheRead", "cache_read_input_tokens", "cached_tokens")
|
|
||||||
cache_write = _usage_int(usage, "cacheWrite", "cache_creation_input_tokens")
|
|
||||||
totals["inputTokens"] += _usage_int(usage, "input", "input_tokens", "prompt_tokens") + cached + cache_write
|
|
||||||
totals["outputTokens"] += _usage_int(usage, "output", "output_tokens", "completion_tokens")
|
|
||||||
totals["cachedTokens"] += cached
|
|
||||||
totals["reasoningTokens"] += _usage_int(usage, "reasoning", "reasoning_tokens")
|
|
||||||
return totals
|
|
||||||
|
|
||||||
|
|
||||||
def _cost_totals(outcome: AgentStreamOutcome) -> tuple[float | None, bool]:
|
|
||||||
"""已知成本求和;任一回合未知则 total 为 None 并标记不完整。"""
|
|
||||||
|
|
||||||
known: list[float] = []
|
|
||||||
complete = True
|
|
||||||
for call in outcome.model_calls:
|
|
||||||
if call.cost_usd is None:
|
|
||||||
complete = False
|
|
||||||
else:
|
|
||||||
known.append(call.cost_usd)
|
|
||||||
if not complete:
|
|
||||||
return (sum(known) if known else None), False
|
|
||||||
return sum(known), True
|
|
||||||
|
|
||||||
|
|
||||||
def _model_call_rows(outcome: AgentStreamOutcome) -> list[dict[str, Any]]:
|
|
||||||
"""把模型回合映射成 agent_trace.persist_agent_evidence 的输入行。"""
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"actual_model_id": call.actual_model_id,
|
|
||||||
"usage": dict(call.usage or {}),
|
|
||||||
"stop_reason": call.stop_reason,
|
|
||||||
"cost_usd": call.cost_usd,
|
|
||||||
"duration_ms": None,
|
|
||||||
}
|
|
||||||
for call in outcome.model_calls
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _write_private_text(path: Path, text: str) -> None:
|
|
||||||
"""创建仅当前用户可读写的运行审计文件。"""
|
|
||||||
|
|
||||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
||||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
||||||
handle.write(text)
|
|
||||||
path.chmod(0o600)
|
|
||||||
|
|
||||||
|
|
||||||
def _write_private_json(path: Path, value: Mapping[str, Any]) -> None:
|
|
||||||
_write_private_text(path, json.dumps(value, ensure_ascii=False, indent=2))
|
|
||||||
|
|
||||||
|
|
||||||
# 工具 server 扩展路径与内建只读文件工具(探索白名单的两类合法成员)。
|
|
||||||
READ_TOOL_EXTENSION = PROJECT_ROOT / "framework" / "adapters" / "pi" / "mcp_bridge.ts"
|
|
||||||
BUILTIN_TOOL_NAMES = frozenset({"read", "grep", "find", "ls"})
|
|
||||||
|
|
||||||
|
|
||||||
def run_dispatch(
|
|
||||||
spec_path: str | Path,
|
|
||||||
*,
|
|
||||||
repo_root: str | Path,
|
|
||||||
policy: ExecutionPolicy,
|
|
||||||
run_id: str | None = None,
|
|
||||||
run_dir: str | Path | None = None,
|
|
||||||
connect_factory: Callable[..., Any] | None = None,
|
|
||||||
launcher: Callable[..., Iterable] | None = None,
|
|
||||||
trigger_source: str = "user",
|
|
||||||
trigger_detail: Mapping[str, Any] | None = None,
|
|
||||||
session_id: str | None = None,
|
|
||||||
session_dir: str | Path | None = None,
|
|
||||||
enable_read_tools: bool = False,
|
|
||||||
) -> tuple[dict[str, Any], int]:
|
|
||||||
"""执行一次完整派发;所有可控失败都返回稳定回执与退出码。"""
|
|
||||||
|
|
||||||
repo_root_path = Path(repo_root).resolve()
|
|
||||||
try:
|
|
||||||
spec = load_spec(spec_path)
|
|
||||||
package = build_task_package(spec, repo_root_path)
|
|
||||||
_check_no_secrets(package.system_prompt)
|
|
||||||
_check_no_secrets(package.user_message)
|
|
||||||
except TaskSpecError as exc:
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"status": "failed",
|
|
||||||
"errorCode": "SPEC_INVALID",
|
|
||||||
"error": str(exc),
|
|
||||||
"specPath": str(spec_path),
|
|
||||||
},
|
|
||||||
EXIT_SPEC_INVALID,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"status": "failed",
|
|
||||||
"errorCode": "SPEC_INVALID",
|
|
||||||
"error": f"任务包不可安全执行: {type(exc).__name__}",
|
|
||||||
"specPath": str(spec_path),
|
|
||||||
},
|
|
||||||
EXIT_SPEC_INVALID,
|
|
||||||
)
|
|
||||||
|
|
||||||
disabled_read_tools = sorted(
|
|
||||||
{
|
|
||||||
tool
|
|
||||||
for tool in package.spec.tool_allowlist
|
|
||||||
if tool in read_tools.TOOL_REGISTRY and not enable_read_tools
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if disabled_read_tools:
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"status": "failed",
|
|
||||||
"errorCode": "READ_TOOLS_NOT_ENABLED",
|
|
||||||
"error": f"任务包含探索工具但未启用工具 server: {disabled_read_tools}",
|
|
||||||
},
|
|
||||||
EXIT_SPEC_INVALID,
|
|
||||||
)
|
|
||||||
policy_kwargs: dict[str, Any] = {"cwd": policy.cwd or str(repo_root_path)}
|
|
||||||
if session_id is not None:
|
|
||||||
policy_kwargs["session_id"] = session_id
|
|
||||||
policy_kwargs["session_dir"] = str(session_dir) if session_dir is not None else None
|
|
||||||
if enable_read_tools:
|
|
||||||
# 工具 server 扩展经环境变量定位只读实现;框架进程继承该环境。
|
|
||||||
os.environ["MUSE_READ_TOOLS_PYTHON"] = sys.executable
|
|
||||||
os.environ["MUSE_READ_TOOLS_SCRIPT"] = str(
|
|
||||||
READ_TOOLS_DIR / "read_tools.py"
|
|
||||||
)
|
|
||||||
policy_kwargs["extension_path"] = str(READ_TOOL_EXTENSION)
|
|
||||||
effective_policy = replace(policy, **policy_kwargs)
|
|
||||||
try:
|
|
||||||
validate_role_execution_policy(package, effective_policy)
|
|
||||||
except ValueError as exc:
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"status": "failed",
|
|
||||||
"errorCode": "ROLE_MODEL_POLICY_MISMATCH",
|
|
||||||
"error": str(exc),
|
|
||||||
"requestedModelId": effective_policy.requested_model_id,
|
|
||||||
},
|
|
||||||
EXIT_SPEC_INVALID,
|
|
||||||
)
|
|
||||||
run_id = run_id or new_run_id(
|
|
||||||
f"agent-{spec.role}", work_id=spec.work_id, target_chapter=spec.target_chapter
|
|
||||||
)
|
|
||||||
if not isinstance(run_id, str) or _RUN_ID_PATTERN.fullmatch(run_id) is None:
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"status": "failed",
|
|
||||||
"errorCode": "RUN_ID_INVALID",
|
|
||||||
"error": "run_id 只能包含 ASCII 字母、数字、点、下划线和短横线,长度不超过 64",
|
|
||||||
},
|
|
||||||
EXIT_SPEC_INVALID,
|
|
||||||
)
|
|
||||||
run_dir_path = Path(run_dir) if run_dir is not None else DEFAULT_RUN_DIR_ROOT / run_id
|
|
||||||
identity = package.as_identity()
|
|
||||||
|
|
||||||
def _receipt(**fields: Any) -> dict[str, Any]:
|
|
||||||
base = {
|
|
||||||
"runId": run_id,
|
|
||||||
"role": spec.role,
|
|
||||||
"framework": effective_policy.framework,
|
|
||||||
"requestedModelId": effective_policy.requested_model_id,
|
|
||||||
"outputSchemaId": spec.output_schema_id,
|
|
||||||
"runDir": str(run_dir_path),
|
|
||||||
**identity,
|
|
||||||
}
|
|
||||||
base.update(fields)
|
|
||||||
return base
|
|
||||||
|
|
||||||
try:
|
|
||||||
if run_dir is None:
|
|
||||||
DEFAULT_RUN_DIR_ROOT.mkdir(parents=True, mode=0o700, exist_ok=True)
|
|
||||||
DEFAULT_RUN_DIR_ROOT.chmod(0o700)
|
|
||||||
run_dir_path.mkdir(parents=True, mode=0o700, exist_ok=False)
|
|
||||||
run_dir_path.chmod(0o700)
|
|
||||||
_write_private_text(
|
|
||||||
run_dir_path / "task-spec.json", Path(spec_path).read_text(encoding="utf-8")
|
|
||||||
)
|
|
||||||
_write_private_text(run_dir_path / "system-prompt.txt", package.system_prompt)
|
|
||||||
_write_private_text(run_dir_path / "user-message.txt", package.user_message)
|
|
||||||
except (OSError, UnicodeError) as exc:
|
|
||||||
return (
|
|
||||||
_receipt(
|
|
||||||
status="failed",
|
|
||||||
errorCode="AUDIT_WRITE_FAILED",
|
|
||||||
error=f"运行审计目录写入失败: {type(exc).__name__}",
|
|
||||||
),
|
|
||||||
EXIT_EVIDENCE_FAILED,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
detail = dict(trigger_detail or {})
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
receipt = _receipt(
|
|
||||||
status="failed",
|
|
||||||
errorCode="TRIGGER_DETAIL_INVALID",
|
|
||||||
error=f"trigger_detail 不是对象: {type(exc).__name__}",
|
|
||||||
)
|
|
||||||
_write_private_json(run_dir_path / "receipt.json", receipt)
|
|
||||||
return receipt, EXIT_SPEC_INVALID
|
|
||||||
detail.setdefault(
|
|
||||||
"dispatch",
|
|
||||||
{
|
|
||||||
"framework": effective_policy.framework,
|
|
||||||
"requestedModelId": effective_policy.requested_model_id,
|
|
||||||
"specSha256": package.spec_sha256,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
detail_json = json.dumps(
|
|
||||||
detail, ensure_ascii=False, sort_keys=True, default=str, allow_nan=False
|
|
||||||
)
|
|
||||||
_check_no_secrets(detail_json)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
receipt = _receipt(
|
|
||||||
status="failed",
|
|
||||||
errorCode="TRIGGER_DETAIL_INVALID",
|
|
||||||
error=f"trigger_detail 不可安全记录: {type(exc).__name__}",
|
|
||||||
)
|
|
||||||
_write_private_json(run_dir_path / "receipt.json", receipt)
|
|
||||||
return receipt, EXIT_SPEC_INVALID
|
|
||||||
try:
|
|
||||||
run_record = start_run(
|
|
||||||
connect=connect_factory,
|
|
||||||
run_id=run_id,
|
|
||||||
work_id=spec.work_id,
|
|
||||||
target_chapter=spec.target_chapter,
|
|
||||||
trigger_source=trigger_source,
|
|
||||||
trigger_detail=detail,
|
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001 - 注册失败不能启动外部 Agent。
|
|
||||||
receipt = _receipt(
|
|
||||||
status="failed",
|
|
||||||
errorCode="RUN_REGISTRY_START_FAILED",
|
|
||||||
error=f"运行登记失败: {type(exc).__name__}",
|
|
||||||
)
|
|
||||||
_write_private_json(run_dir_path / "receipt.json", receipt)
|
|
||||||
return receipt, EXIT_EVIDENCE_FAILED
|
|
||||||
if run_record["status"] != "started":
|
|
||||||
receipt = _receipt(
|
|
||||||
status="failed",
|
|
||||||
errorCode="RUN_ID_EXISTS",
|
|
||||||
error="run_id 已存在,拒绝覆盖既有运行证据",
|
|
||||||
)
|
|
||||||
_write_private_json(run_dir_path / "receipt.json", receipt)
|
|
||||||
return receipt, EXIT_EVIDENCE_FAILED
|
|
||||||
|
|
||||||
writer = AgentTraceWriter(
|
|
||||||
run_id=run_id,
|
|
||||||
framework=effective_policy.framework,
|
|
||||||
agent_role=spec.role,
|
|
||||||
connect=connect_factory,
|
|
||||||
)
|
|
||||||
runner = PiAgentRunner(launcher=launcher)
|
|
||||||
|
|
||||||
def _failed(
|
|
||||||
error_code: str,
|
|
||||||
error: str,
|
|
||||||
exit_code: int,
|
|
||||||
*,
|
|
||||||
agent_failed: bool = False,
|
|
||||||
session_id: str | None = None,
|
|
||||||
final_message: str | None = None,
|
|
||||||
evidence: Mapping[str, Any] | None = None,
|
|
||||||
cause_error_code: str | None = None,
|
|
||||||
) -> tuple[dict[str, Any], int]:
|
|
||||||
"""尽力闭合失败终态;留痕本身失败时升级为证据错误,保留原始原因码。"""
|
|
||||||
|
|
||||||
failures: list[str] = []
|
|
||||||
if final_message is not None:
|
|
||||||
try:
|
|
||||||
_write_private_text(run_dir_path / "final-message.txt", final_message)
|
|
||||||
except OSError as exc:
|
|
||||||
failures.append(type(exc).__name__)
|
|
||||||
if agent_failed:
|
|
||||||
try:
|
|
||||||
writer.emit(
|
|
||||||
"agent.failed",
|
|
||||||
status="error",
|
|
||||||
requested_model_id=effective_policy.requested_model_id,
|
|
||||||
details={"errorCode": error_code},
|
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001 - 继续尝试写 run.failed/终态。
|
|
||||||
failures.append(type(exc).__name__)
|
|
||||||
try:
|
|
||||||
writer.emit(
|
|
||||||
"run.failed",
|
|
||||||
status="error",
|
|
||||||
requested_model_id=effective_policy.requested_model_id,
|
|
||||||
raw_ref=(evidence or {}).get("transcriptId"),
|
|
||||||
details={"errorCode": error_code},
|
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001 - 继续尝试闭合 example_run。
|
|
||||||
failures.append(type(exc).__name__)
|
|
||||||
try:
|
|
||||||
finish_run(
|
|
||||||
run_id,
|
|
||||||
"failed",
|
|
||||||
trigger_detail={"errorCode": error_code},
|
|
||||||
connect=connect_factory,
|
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001 - 回执必须揭示终态未能闭合。
|
|
||||||
failures.append(type(exc).__name__)
|
|
||||||
|
|
||||||
fields: dict[str, Any] = {
|
|
||||||
"status": "failed",
|
|
||||||
"errorCode": error_code,
|
|
||||||
"error": error,
|
|
||||||
"sessionId": session_id,
|
|
||||||
}
|
|
||||||
if evidence is not None:
|
|
||||||
fields["evidence"] = dict(evidence)
|
|
||||||
if cause_error_code is not None:
|
|
||||||
fields["causeErrorCode"] = cause_error_code
|
|
||||||
if failures:
|
|
||||||
fields.update(
|
|
||||||
{
|
|
||||||
"causeErrorCode": cause_error_code or error_code,
|
|
||||||
"errorCode": "EVIDENCE_PERSIST_FAILED",
|
|
||||||
"error": "失败终态留痕未完整写入",
|
|
||||||
"finalizationErrorTypes": sorted(set(failures)),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
exit_code = EXIT_EVIDENCE_FAILED
|
|
||||||
receipt = _receipt(**fields)
|
|
||||||
try:
|
|
||||||
_write_private_json(run_dir_path / "receipt.json", receipt)
|
|
||||||
except OSError:
|
|
||||||
receipt["receiptFileWritten"] = False
|
|
||||||
exit_code = EXIT_EVIDENCE_FAILED
|
|
||||||
return receipt, exit_code
|
|
||||||
|
|
||||||
try:
|
|
||||||
writer.emit(
|
|
||||||
"run.started",
|
|
||||||
status="ok",
|
|
||||||
requested_model_id=effective_policy.requested_model_id,
|
|
||||||
details={"specSha256": package.spec_sha256, "toolAllowlist": list(spec.tool_allowlist)},
|
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001 - 未留起始事件时不得启动框架。
|
|
||||||
return _failed(
|
|
||||||
"EVIDENCE_PERSIST_FAILED",
|
|
||||||
f"运行起始事件写入失败: {type(exc).__name__}",
|
|
||||||
EXIT_EVIDENCE_FAILED,
|
|
||||||
)
|
|
||||||
|
|
||||||
transcript_path = run_dir_path / "transcript.jsonl"
|
|
||||||
|
|
||||||
def _remove_local_raw() -> None:
|
|
||||||
for path in (transcript_path, run_dir_path / "final-message.txt"):
|
|
||||||
try:
|
|
||||||
path.unlink(missing_ok=True)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _persist_failure_evidence(
|
|
||||||
failed_outcome: AgentStreamOutcome | None,
|
|
||||||
) -> tuple[dict[str, Any] | None, str | None]:
|
|
||||||
"""失败也尽量把已有转录和模型回合写入同一套 raw 证据。"""
|
|
||||||
|
|
||||||
if failed_outcome is None:
|
|
||||||
return None, None
|
|
||||||
try:
|
|
||||||
transcript_text = transcript_path.read_text(encoding="utf-8")
|
|
||||||
except (OSError, UnicodeError):
|
|
||||||
return None, "EVIDENCE_PERSIST_FAILED"
|
|
||||||
if not transcript_text.strip():
|
|
||||||
return None, None
|
|
||||||
try:
|
|
||||||
_check_no_secrets(transcript_text)
|
|
||||||
except ValueError:
|
|
||||||
_remove_local_raw()
|
|
||||||
return None, "RAW_SECRET_DETECTED"
|
|
||||||
try:
|
|
||||||
evidence_result = persist_agent_evidence(
|
|
||||||
run_id=run_id,
|
|
||||||
agent_role=spec.role,
|
|
||||||
system_prompt=package.system_prompt,
|
|
||||||
user_message=package.user_message,
|
|
||||||
final_message=failed_outcome.final_text,
|
|
||||||
transcript=transcript_text,
|
|
||||||
model_calls=_model_call_rows(failed_outcome),
|
|
||||||
requested_model_id=effective_policy.requested_model_id,
|
|
||||||
connect=connect_factory,
|
|
||||||
)
|
|
||||||
return evidence_result, None
|
|
||||||
except Exception:
|
|
||||||
return None, "EVIDENCE_PERSIST_FAILED"
|
|
||||||
|
|
||||||
try:
|
|
||||||
transcript_fd = os.open(
|
|
||||||
transcript_path,
|
|
||||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
|
||||||
0o600,
|
|
||||||
)
|
|
||||||
with os.fdopen(transcript_fd, "wb") as transcript_file:
|
|
||||||
outcome = runner.run(
|
|
||||||
package.as_framework_request(
|
|
||||||
session_mode="continue" if session_id is not None else "fresh",
|
|
||||||
request_id=run_id,
|
|
||||||
),
|
|
||||||
effective_policy,
|
|
||||||
writer,
|
|
||||||
timeout_seconds=spec.max_duration_seconds,
|
|
||||||
raw_sink=transcript_file.write,
|
|
||||||
)
|
|
||||||
transcript_path.chmod(0o600)
|
|
||||||
except FrameworkError as exc:
|
|
||||||
failed_outcome = exc.outcome
|
|
||||||
failure_evidence, evidence_error = _persist_failure_evidence(failed_outcome)
|
|
||||||
if evidence_error is not None:
|
|
||||||
return _failed(
|
|
||||||
evidence_error,
|
|
||||||
"框架失败证据未能安全落库",
|
|
||||||
EXIT_EVIDENCE_FAILED,
|
|
||||||
agent_failed=True,
|
|
||||||
session_id=failed_outcome.session_id if failed_outcome else None,
|
|
||||||
cause_error_code=exc.error_code,
|
|
||||||
)
|
|
||||||
return _failed(
|
|
||||||
exc.error_code,
|
|
||||||
str(exc),
|
|
||||||
EXIT_FRAMEWORK_FAILED,
|
|
||||||
agent_failed=True,
|
|
||||||
session_id=failed_outcome.session_id if failed_outcome else None,
|
|
||||||
final_message=failed_outcome.final_text if failed_outcome else None,
|
|
||||||
evidence=failure_evidence,
|
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001 - 事件/本地转录失败属于证据失败。
|
|
||||||
return _failed(
|
|
||||||
"EVIDENCE_PERSIST_FAILED",
|
|
||||||
f"框架执行留痕失败: {type(exc).__name__}",
|
|
||||||
EXIT_EVIDENCE_FAILED,
|
|
||||||
agent_failed=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
transcript_text = transcript_path.read_text(encoding="utf-8")
|
|
||||||
_check_no_secrets(transcript_text)
|
|
||||||
except ValueError as exc:
|
|
||||||
_remove_local_raw()
|
|
||||||
return _failed(
|
|
||||||
"RAW_SECRET_DETECTED",
|
|
||||||
"框架转录含疑似凭据,已拒绝留存",
|
|
||||||
EXIT_EVIDENCE_FAILED,
|
|
||||||
)
|
|
||||||
except (OSError, UnicodeError) as exc:
|
|
||||||
return _failed(
|
|
||||||
"EVIDENCE_PERSIST_FAILED",
|
|
||||||
f"框架转录回读失败: {type(exc).__name__}",
|
|
||||||
EXIT_EVIDENCE_FAILED,
|
|
||||||
session_id=outcome.session_id,
|
|
||||||
)
|
|
||||||
if not transcript_text.strip():
|
|
||||||
return _failed(
|
|
||||||
"TRANSCRIPT_EMPTY",
|
|
||||||
"框架转录为空",
|
|
||||||
EXIT_FRAMEWORK_FAILED,
|
|
||||||
session_id=outcome.session_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
framework_artifact = normalize_pi_transcript(
|
|
||||||
transcript_path,
|
|
||||||
run_dir_path / "framework-events.jsonl",
|
|
||||||
run_id=run_id,
|
|
||||||
framework_version="pi-json-v1",
|
|
||||||
)
|
|
||||||
except (OSError, UnicodeError, ValueError) as exc:
|
|
||||||
return _failed(
|
|
||||||
"FRAMEWORK_ARTIFACT_INVALID",
|
|
||||||
f"通用框架事件工件生成失败: {type(exc).__name__}",
|
|
||||||
EXIT_EVIDENCE_FAILED,
|
|
||||||
session_id=outcome.session_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
evidence = persist_agent_evidence(
|
|
||||||
run_id=run_id,
|
|
||||||
agent_role=spec.role,
|
|
||||||
system_prompt=package.system_prompt,
|
|
||||||
user_message=package.user_message,
|
|
||||||
final_message=outcome.final_text,
|
|
||||||
transcript=transcript_text,
|
|
||||||
model_calls=_model_call_rows(outcome),
|
|
||||||
requested_model_id=effective_policy.requested_model_id,
|
|
||||||
connect=connect_factory,
|
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001 - 模型成功但证据失败时必须失败关闭。
|
|
||||||
return _failed(
|
|
||||||
"EVIDENCE_PERSIST_FAILED",
|
|
||||||
f"框架证据落库失败: {type(exc).__name__}",
|
|
||||||
EXIT_EVIDENCE_FAILED,
|
|
||||||
session_id=outcome.session_id,
|
|
||||||
final_message=outcome.final_text,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
structured = validate_structured_output(outcome.final_text or "", spec)
|
|
||||||
except OutputInvalidError as exc:
|
|
||||||
return _failed(
|
|
||||||
"OUTPUT_SCHEMA_INVALID",
|
|
||||||
str(exc),
|
|
||||||
EXIT_OUTPUT_INVALID,
|
|
||||||
session_id=outcome.session_id,
|
|
||||||
final_message=outcome.final_text,
|
|
||||||
evidence=evidence,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
_write_private_text(run_dir_path / "final-message.txt", outcome.final_text or "")
|
|
||||||
_write_private_json(run_dir_path / "output.json", structured)
|
|
||||||
except OSError as exc:
|
|
||||||
return _failed(
|
|
||||||
"AUDIT_WRITE_FAILED",
|
|
||||||
f"运行结果审计文件写入失败: {type(exc).__name__}",
|
|
||||||
EXIT_EVIDENCE_FAILED,
|
|
||||||
session_id=outcome.session_id,
|
|
||||||
evidence=evidence,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 依赖清单:本次运行实际读取了什么(工具调用序列与参数),链路透视的读侧材料。
|
|
||||||
dependency_count = len(outcome.tool_calls)
|
|
||||||
if dependency_count:
|
|
||||||
dependencies = [
|
|
||||||
{
|
|
||||||
"seq": index + 1,
|
|
||||||
"tool": call.name,
|
|
||||||
"args": dict(call.args) if call.args else {},
|
|
||||||
"isError": call.is_error,
|
|
||||||
}
|
|
||||||
for index, call in enumerate(outcome.tool_calls)
|
|
||||||
]
|
|
||||||
try:
|
|
||||||
_write_private_json(run_dir_path / "dependencies.json", dependencies)
|
|
||||||
except OSError as exc:
|
|
||||||
return _failed(
|
|
||||||
"AUDIT_WRITE_FAILED",
|
|
||||||
f"依赖清单写入失败: {type(exc).__name__}",
|
|
||||||
EXIT_EVIDENCE_FAILED,
|
|
||||||
session_id=outcome.session_id,
|
|
||||||
evidence=evidence,
|
|
||||||
)
|
|
||||||
|
|
||||||
usage = _usage_totals(outcome)
|
|
||||||
total_cost, cost_complete = _cost_totals(outcome)
|
|
||||||
try:
|
|
||||||
writer.emit(
|
|
||||||
"run.completed",
|
|
||||||
status="ok",
|
|
||||||
requested_model_id=effective_policy.requested_model_id,
|
|
||||||
actual_model_id=outcome.model_calls[-1].actual_model_id,
|
|
||||||
usage={
|
|
||||||
"input": usage["inputTokens"] - usage["cachedTokens"],
|
|
||||||
"output": usage["outputTokens"],
|
|
||||||
"cacheRead": usage["cachedTokens"],
|
|
||||||
},
|
|
||||||
cost_usd=total_cost,
|
|
||||||
raw_ref=evidence.get("transcriptId"),
|
|
||||||
details={
|
|
||||||
"sessionId": outcome.session_id,
|
|
||||||
"turns": outcome.turns,
|
|
||||||
"toolCalls": len(outcome.tool_calls),
|
|
||||||
"unknownFrameworkEvents": list(outcome.unknown_event_types),
|
|
||||||
"costComplete": cost_complete,
|
|
||||||
"leaseId": evidence.get("leaseId"),
|
|
||||||
"llmCallIds": evidence.get("llmCallIds"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
finish_run(run_id, "completed", connect=connect_factory)
|
|
||||||
except Exception as exc: # noqa: BLE001 - 成功终态与终态事件必须一起可见。
|
|
||||||
return _failed(
|
|
||||||
"RUN_FINALIZE_FAILED",
|
|
||||||
f"成功终态写入失败: {type(exc).__name__}",
|
|
||||||
EXIT_EVIDENCE_FAILED,
|
|
||||||
session_id=outcome.session_id,
|
|
||||||
evidence=evidence,
|
|
||||||
)
|
|
||||||
|
|
||||||
receipt = _receipt(
|
|
||||||
status="completed",
|
|
||||||
sessionId=outcome.session_id,
|
|
||||||
durationMs=outcome.duration_ms,
|
|
||||||
turns=outcome.turns,
|
|
||||||
toolCallCount=len(outcome.tool_calls),
|
|
||||||
unknownFrameworkEvents=list(outcome.unknown_event_types),
|
|
||||||
dependencies=(
|
|
||||||
{"count": dependency_count, "file": "dependencies.json"}
|
|
||||||
if dependency_count
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
modelCallCount=len(outcome.model_calls),
|
|
||||||
actualModelIds=[call.actual_model_id for call in outcome.model_calls],
|
|
||||||
usage=usage,
|
|
||||||
totalCostUsd=round(total_cost, 6) if total_cost is not None else None,
|
|
||||||
costComplete=cost_complete,
|
|
||||||
finalMessageSha256=_sha256_bare(outcome.final_text or ""),
|
|
||||||
structuredOutputSha256=_sha256_bare(
|
|
||||||
json.dumps(structured, ensure_ascii=False, sort_keys=True)
|
|
||||||
),
|
|
||||||
evidence=evidence,
|
|
||||||
frameworkArtifact=framework_artifact,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
_write_private_json(run_dir_path / "receipt.json", receipt)
|
|
||||||
except OSError:
|
|
||||||
receipt["receiptFileWritten"] = False
|
|
||||||
return receipt, EXIT_OK
|
|
||||||
|
|
||||||
|
|
||||||
def _sha256_bare(text: str) -> str:
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
|||||||
@ -2002,18 +2002,35 @@
|
|||||||
"skill_behavior_eval": false,
|
"skill_behavior_eval": false,
|
||||||
"classification_confidence": "high",
|
"classification_confidence": "high",
|
||||||
"classification_basis": "验证迁移后的只读看板、候选决策和经验确认入口从独立仓根加载业务脚本,并覆盖写通道页面地标与键盘可达滚动区。"
|
"classification_basis": "验证迁移后的只读看板、候选决策和经验确认入口从独立仓根加载业务脚本,并覆盖写通道页面地标与键盘可达滚动区。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "tests/architecture/test_runtime_purity.py",
|
||||||
|
"scope": "domain",
|
||||||
|
"owner_skill_or_domain": "architecture",
|
||||||
|
"kind": "tool_contract",
|
||||||
|
"evidence_level": "static_structure",
|
||||||
|
"requires": [
|
||||||
|
"offline",
|
||||||
|
"filesystem"
|
||||||
|
],
|
||||||
|
"side_effects": [
|
||||||
|
"none"
|
||||||
|
],
|
||||||
|
"skill_behavior_eval": false,
|
||||||
|
"classification_confidence": "high",
|
||||||
|
"classification_basis": "扫描 runtime 目录,阻断对 Muse 业务模块的反向 import,并要求 dispatch_agent_task.py 保持薄 CLI。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"summary": {
|
"summary": {
|
||||||
"entry_count": 122,
|
"entry_count": 123,
|
||||||
"by_scope": {
|
"by_scope": {
|
||||||
"other": 1,
|
"other": 1,
|
||||||
"runtime_skill": 103,
|
"runtime_skill": 103,
|
||||||
"harness": 3,
|
"harness": 3,
|
||||||
"domain": 15
|
"domain": 16
|
||||||
},
|
},
|
||||||
"by_kind": {
|
"by_kind": {
|
||||||
"tool_contract": 42,
|
"tool_contract": 43,
|
||||||
"skill_behavior_eval": 1,
|
"skill_behavior_eval": 1,
|
||||||
"harness_self_test": 3,
|
"harness_self_test": 3,
|
||||||
"domain_eval": 4,
|
"domain_eval": 4,
|
||||||
@ -2026,8 +2043,8 @@
|
|||||||
"by_evidence_level": {
|
"by_evidence_level": {
|
||||||
"deterministic_offline": 101,
|
"deterministic_offline": 101,
|
||||||
"real_dependency_integration": 11,
|
"real_dependency_integration": 11,
|
||||||
"static_structure": 10
|
"static_structure": 11
|
||||||
},
|
},
|
||||||
"total": 122
|
"total": 123
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
runtime/__init__.py
Normal file
1
runtime/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""执行底座:协议、执行器与 run 目录。不认识 Muse 业务对象。"""
|
||||||
33
runtime/agent_executor.py
Normal file
33
runtime/agent_executor.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
"""多轮 Agent 调用。不认识 Muse 业务对象。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Callable, Iterable
|
||||||
|
|
||||||
|
from framework.adapters.pi.runner import (
|
||||||
|
AgentStreamOutcome,
|
||||||
|
ExecutionPolicy,
|
||||||
|
PiAgentRunner,
|
||||||
|
TraceSink,
|
||||||
|
)
|
||||||
|
from framework.primitives.execution import FrameworkExecutionRequest
|
||||||
|
|
||||||
|
|
||||||
|
def execute_agent(
|
||||||
|
request: FrameworkExecutionRequest,
|
||||||
|
policy: ExecutionPolicy,
|
||||||
|
sink: TraceSink,
|
||||||
|
*,
|
||||||
|
timeout_seconds: float,
|
||||||
|
raw_sink: Callable[[bytes], None] | None = None,
|
||||||
|
launcher: Callable[..., Iterable[bytes]] | None = None,
|
||||||
|
) -> AgentStreamOutcome:
|
||||||
|
"""把通用执行请求交给当前生产宿主适配器。"""
|
||||||
|
|
||||||
|
return PiAgentRunner(launcher=launcher).run(
|
||||||
|
request,
|
||||||
|
policy,
|
||||||
|
sink,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
raw_sink=raw_sink,
|
||||||
|
)
|
||||||
81
runtime/runs.py
Normal file
81
runtime/runs.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
"""不可变 run 目录读写。不认识 Muse 业务对象。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from collections.abc import Iterator, Mapping
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, BinaryIO
|
||||||
|
|
||||||
|
DEFAULT_RUN_DIR_ROOT = Path("/tmp/muse-agent-runs")
|
||||||
|
_RUN_ID_PATTERN = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
||||||
|
|
||||||
|
|
||||||
|
class RunDirectoryError(OSError):
|
||||||
|
"""run 目录创建或写入失败。"""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_run_id(run_id: str) -> bool:
|
||||||
|
return isinstance(run_id, str) and _RUN_ID_PATTERN.fullmatch(run_id) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_run_dir(run_id: str, run_dir: str | Path | None = None) -> Path:
|
||||||
|
"""创建仅当前用户可访问的 run 目录;默认根目录可复用,本次目录不可覆盖。"""
|
||||||
|
|
||||||
|
run_dir_path = Path(run_dir) if run_dir is not None else DEFAULT_RUN_DIR_ROOT / run_id
|
||||||
|
if run_dir is None:
|
||||||
|
DEFAULT_RUN_DIR_ROOT.mkdir(parents=True, mode=0o700, exist_ok=True)
|
||||||
|
DEFAULT_RUN_DIR_ROOT.chmod(0o700)
|
||||||
|
run_dir_path.mkdir(parents=True, mode=0o700, exist_ok=False)
|
||||||
|
run_dir_path.chmod(0o700)
|
||||||
|
return run_dir_path
|
||||||
|
|
||||||
|
|
||||||
|
def write_private_text(path: Path, text: str) -> None:
|
||||||
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(text)
|
||||||
|
path.chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def write_private_json(path: Path, value: Mapping[str, Any]) -> None:
|
||||||
|
write_private_text(path, json.dumps(value, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def seed_run_inputs(
|
||||||
|
run_dir: Path,
|
||||||
|
*,
|
||||||
|
spec_text: str,
|
||||||
|
system_prompt: str,
|
||||||
|
user_message: str,
|
||||||
|
) -> None:
|
||||||
|
write_private_text(run_dir / "task-spec.json", spec_text)
|
||||||
|
write_private_text(run_dir / "system-prompt.txt", system_prompt)
|
||||||
|
write_private_text(run_dir / "user-message.txt", user_message)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def open_transcript(run_dir: Path) -> Iterator[BinaryIO]:
|
||||||
|
path = run_dir / "transcript.jsonl"
|
||||||
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
|
handle = os.fdopen(fd, "wb")
|
||||||
|
try:
|
||||||
|
yield handle
|
||||||
|
path.chmod(0o600)
|
||||||
|
finally:
|
||||||
|
handle.close()
|
||||||
|
|
||||||
|
|
||||||
|
def read_transcript_text(run_dir: Path) -> str:
|
||||||
|
return (run_dir / "transcript.jsonl").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def remove_local_raw(run_dir: Path) -> None:
|
||||||
|
for name in ("transcript.jsonl", "final-message.txt"):
|
||||||
|
try:
|
||||||
|
(run_dir / name).unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
@ -27,6 +27,7 @@ DASHBOARD_ROOTS = (pathlib.Path("muse") / "authority" / "studio" / "read",)
|
|||||||
ACTIVE_RUNTIME_RELATIVE_ROOTS = (
|
ACTIVE_RUNTIME_RELATIVE_ROOTS = (
|
||||||
pathlib.Path(".agent") / "skills",
|
pathlib.Path(".agent") / "skills",
|
||||||
pathlib.Path("framework"),
|
pathlib.Path("framework"),
|
||||||
|
pathlib.Path("runtime"),
|
||||||
pathlib.Path("muse"),
|
pathlib.Path("muse"),
|
||||||
pathlib.Path("muse") / "lifecycle" / "quality" / "harness",
|
pathlib.Path("muse") / "lifecycle" / "quality" / "harness",
|
||||||
pathlib.Path("muse") / "platform" / "llm",
|
pathlib.Path("muse") / "platform" / "llm",
|
||||||
|
|||||||
69
tests/architecture/test_runtime_purity.py
Normal file
69
tests/architecture/test_runtime_purity.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"""执行底座纯度门:runtime 不得反向依赖 Muse 业务实现。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = next(
|
||||||
|
parent
|
||||||
|
for parent in (pathlib.Path(__file__).resolve().parent, *pathlib.Path(__file__).resolve().parents)
|
||||||
|
if (parent / "AGENTS.md").is_file() and (parent / ".git").exists()
|
||||||
|
)
|
||||||
|
FORBIDDEN_IMPORTS = (
|
||||||
|
"agent_trace",
|
||||||
|
"muse_role",
|
||||||
|
"muse_role_contract",
|
||||||
|
"muse_db",
|
||||||
|
"psycopg",
|
||||||
|
"fixed-opus",
|
||||||
|
"role_task",
|
||||||
|
"role_policy",
|
||||||
|
"run_registry",
|
||||||
|
"persist_raw",
|
||||||
|
"read_tools",
|
||||||
|
)
|
||||||
|
DISPATCH_CLI = (
|
||||||
|
ROOT
|
||||||
|
/ "muse"
|
||||||
|
/ "lifecycle"
|
||||||
|
/ "dispatch"
|
||||||
|
/ "skills"
|
||||||
|
/ "dispatch-agent-task"
|
||||||
|
/ "scripts"
|
||||||
|
/ "dispatch_agent_task.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimePurityTest(unittest.TestCase):
|
||||||
|
def test_runtime_does_not_import_muse_business(self) -> None:
|
||||||
|
offenders: list[str] = []
|
||||||
|
import_pattern = re.compile(
|
||||||
|
r"(?:from|import)\s+(?:" + "|".join(map(re.escape, FORBIDDEN_IMPORTS)) + r")\b"
|
||||||
|
)
|
||||||
|
muse_pattern = re.compile(r"(?:from|import)\s+muse(?:\.|\s)")
|
||||||
|
runtime_root = ROOT / "runtime"
|
||||||
|
self.assertTrue(runtime_root.is_dir(), "runtime/ 必须存在")
|
||||||
|
for path in runtime_root.rglob("*"):
|
||||||
|
if not path.is_file() or "__pycache__" in path.parts:
|
||||||
|
continue
|
||||||
|
if path.suffix not in {".py", ".ts", ".md"}:
|
||||||
|
continue
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
if import_pattern.search(text) or muse_pattern.search(text):
|
||||||
|
offenders.append(path.relative_to(ROOT).as_posix())
|
||||||
|
self.assertEqual(offenders, [], "runtime 反向依赖 Muse 业务:\n" + "\n".join(offenders))
|
||||||
|
|
||||||
|
def test_dispatch_agent_task_is_thin_cli(self) -> None:
|
||||||
|
lines = DISPATCH_CLI.read_text(encoding="utf-8").splitlines()
|
||||||
|
self.assertLess(
|
||||||
|
len(lines),
|
||||||
|
100,
|
||||||
|
f"dispatch_agent_task.py 必须 < 100 行,实际 {len(lines)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
x
Reference in New Issue
Block a user