diff --git a/README.md b/README.md index 70ac3a23..6631489d 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ OpenPI 把成熟 Coding Agent 的工作习惯做成 Pi-native 能力,但不复 | 工作面 | 已包含的能力 | | ------------ | --------------------------------------------------------------------------------------------------------- | | 执行 | Background Terminal、Pi-native Subagent、Dynamic Workflow、隔离 Worktree | -| 编排 | `pipeline` / `parallel`、结构化输出、Result Handoff、Operator、Acceptance Ledger、Safe Replay、派生 Graph | +| 编排 | `pipeline` / `parallel`、结构化输出、Result Handoff、Operator、Safe Replay、派生 Graph | | 连续性 | Tasks、Goal、Plan Mode、Context Pivot、Session Browser、Session-scoped Cron | | 自定义 Agent | `explorer` / `implementer` / `reviewer` / `advisor`,支持全局与项目角色文件、独立模型与 effort | | 终端工作台 | 自定义 Footer 与任务栏、运行状态、紧凑 Tool Result、Next-action Suggestion、Git / PR 信号 | @@ -270,7 +270,7 @@ return agent("Synthesize the verified findings", { | `phase()` | 标记当前阶段 | | `log()` | 向实时界面与最终报告追加一行进度 | | `usage()` | 读取累计 Token、缓存、成本及本轮并发/调用余量;Token 是 lower bound,不是预算器 | -| `agent()` | 启动 Pi Agent;支持 role、schema、acceptance、inputs、operator 与 worktree | +| `agent()` | 启动 Pi Agent;支持 role、schema、inputs、operator 与 worktree | | `pipeline()` | 每个 item 完成上阶段后立即进入下一阶段;多阶段 fan-out 的默认选择 | | `parallel()` | 并发 barrier;只在下一阶段确实需要全部结果时使用 | @@ -308,9 +308,11 @@ OpenPI 把一次调用拆成可以审计的生命周期,而不是把“进程 `operator: "name"` 在同一 Run 内复用一个内存 Child Session,并把同名 activation 串行化。首个 activation 固定 model、role/tool surface、effort、structured mode 与 cwd。Operator 不与 per-call Worktree 或 Replay 混用,也不承诺跨重启持久记忆。 -### Explicit Acceptance +### Deprecated Acceptance compatibility -可选 `acceptance: { criteria: [...] }` 要求同一个 Agent 返回 evidence ledger。支持 1–32 条验收条件;`description` 为人类可读说明(1–500 字符),可选的 `requiredEvidence` 为字符串数组(至多 16 个标签,每项至多 120 字符),子 Agent 必须在 `acceptance.criteria[].evidence` 中返回完全匹配的标签: +`acceptance` 自 OpenPI 0.5 起弃用,并计划在 1.0 删除。兼容期仍读取旧 DSL、journal 与 artifact,但 ledger 只是执行任务的同一个模型所写的 `model-self-attestation`,不是 runtime-observed evidence,也不再决定 `agent().ok`;`ok` 只表示 child execution 与结果制品是否成功。 + +新 Workflow 应使用普通 `schema` 返回判断材料,由父模型结合退出码、测试结果、文件指纹和 tool receipts 等真实运行时事实综合判断。旧的可选 `acceptance: { criteria: [...] }` 仍可要求同一个 Agent 返回 ledger: ```js acceptance: { @@ -324,7 +326,7 @@ acceptance: { } ``` -条件缺失、格式错误或被拒绝时,调用返回 `ok: false`,但原始输出与 ledger 仍保留。OpenPI 不会暗中再启动 reviewer、Shell 或额外 Judge 模型。 +条件缺失、格式错误或被拒绝时,原始输出与 ledger 仍保留并明确标注 authority/deprecation;它们不会把成功执行改成失败,也不会把失败执行改成成功。OpenPI 不会暗中再启动 reviewer、Shell 或额外 Judge 模型。 未设置 `requiredEvidence` 的 criterion 是对 `description` 的自我声明,不是有证据约束的验收门禁;需要 evidence-backed gate 时,必须声明所需证据标签。 diff --git a/extensions/workflows/acceptance.ts b/extensions/workflows/acceptance.ts index 853d5c07..8012e206 100644 --- a/extensions/workflows/acceptance.ts +++ b/extensions/workflows/acceptance.ts @@ -22,6 +22,25 @@ export interface AcceptanceLedger { readonly status: "accepted" | "rejected" | "missing" | "malformed"; readonly criteria: readonly AcceptanceCriterionResult[]; readonly errors: readonly string[]; + /** Child-authored judgment retained only for migration; never a runtime fact. */ + readonly authority?: "model-self-attestation"; + readonly deprecated?: { + readonly since: "0.5"; + readonly removal: "1.0"; + }; +} + +export const ACCEPTANCE_DEPRECATION_WARNING = + "acceptance is deprecated since OpenPI 0.5 and will be removed in 1.0; it is model self-attestation, not runtime-verified evidence, and does not determine ok"; + +function ledger( + value: Omit, +): AcceptanceLedger { + return { + ...value, + authority: "model-self-attestation", + deprecated: { since: "0.5", removal: "1.0" }, + }; } const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; @@ -169,6 +188,12 @@ export function isAcceptanceLedger(value: unknown): value is AcceptanceLedger { value.status === "rejected" || value.status === "missing" || value.status === "malformed") && + (value.authority === undefined || + value.authority === "model-self-attestation") && + (value.deprecated === undefined || + (record(value.deprecated) && + value.deprecated.since === "0.5" && + value.deprecated.removal === "1.0")) && value.errors.every((error) => typeof error === "string") && value.criteria.every( (criterion) => @@ -187,7 +212,7 @@ export function acceptanceInstruction(contract: AcceptanceContract) { `- ${criterion.id}: ${criterion.description}${criterion.requiredEvidence?.length ? `; required evidence labels: ${criterion.requiredEvidence.join(", ")}` : ""}`, ); return [ - "Acceptance is explicit evidence, not a self-awarded success claim.", + "Deprecated compatibility protocol: this acceptance ledger is your own model self-attestation, not runtime-verified evidence, and it does not determine execution success.", "Include an `acceptance.criteria` array in structured_output with exactly these ids. Mark rejected when the criterion is not demonstrated. Evidence entries must be concise labels or concrete references; do not invent evidence.", ...criteria, ].join("\n"); @@ -198,19 +223,19 @@ export function evaluateAcceptance( structured: unknown, ): AcceptanceLedger { if (!record(structured) || !record(structured.acceptance)) { - return { + return ledger({ status: "missing", criteria: [], errors: ["structured result omitted acceptance"], - }; + }); } const rawCriteria = structured.acceptance.criteria; if (!Array.isArray(rawCriteria)) { - return { + return ledger({ status: "malformed", criteria: [], errors: ["acceptance.criteria is not an array"], - }; + }); } const errors: string[] = []; const byId = new Map(); @@ -265,14 +290,15 @@ export function evaluateAcceptance( errors.push(`unexpected acceptance criterion "${id}"`); } } - if (errors.length) return { status: "malformed", criteria: results, errors }; - return { + if (errors.length) + return ledger({ status: "malformed", criteria: results, errors }); + return ledger({ status: results.every((result) => result.status === "accepted") ? "accepted" : "rejected", criteria: results, errors: [], - }; + }); } export function applyAcceptance(options: { @@ -284,15 +310,13 @@ export function applyAcceptance(options: { const ledger = options.contract ? evaluateAcceptance(options.contract, options.structured) : undefined; - const acceptanceError = - ledger && ledger.status !== "accepted" - ? `Acceptance ${ledger.status}${ledger.errors.length ? `: ${ledger.errors.join("; ")}` : ": one or more criteria were rejected"}` - : undefined; - const ok = options.agentOk && !acceptanceError; - const error = ok - ? undefined - : options.agentError - ? `${options.agentError}${acceptanceError ? `; ${acceptanceError}` : ""}` - : (acceptanceError ?? "Agent failed"); - return { ok, ...(ledger ? { ledger } : {}), ...(error ? { error } : {}) }; + const ok = options.agentOk; + const error = ok ? undefined : (options.agentError ?? "Agent failed"); + return { + ok, + ...(ledger + ? { ledger, acceptanceWarning: ACCEPTANCE_DEPRECATION_WARNING } + : {}), + ...(error ? { error } : {}), + }; } diff --git a/extensions/workflows/completion-projection.ts b/extensions/workflows/completion-projection.ts index 61e99c7b..e22a88ec 100644 --- a/extensions/workflows/completion-projection.ts +++ b/extensions/workflows/completion-projection.ts @@ -230,7 +230,9 @@ function buildOperatorReport( : "running"; lines.push( `- [${agent.label}]${agent.phase ? ` (${agent.phase})` : ""} ${state}` + - (agent.acceptance ? ` · acceptance ${agent.acceptance.status}` : "") + + (agent.acceptance + ? ` · deprecated model self-attestation ${agent.acceptance.status}` + : "") + (agent.error ? ` — ${agent.error}` : ""), ); } diff --git a/extensions/workflows/index.ts b/extensions/workflows/index.ts index 6e41341c..55fc91b5 100644 --- a/extensions/workflows/index.ts +++ b/extensions/workflows/index.ts @@ -537,6 +537,8 @@ interface ScriptAgentResult { /** Opaque same-run handle for bounded downstream handoff. */ ref?: string; acceptance?: AgentRecord["acceptance"]; + /** Present only for the deprecated model self-attestation compatibility path. */ + acceptanceWarning?: string; error?: string; } @@ -1880,6 +1882,9 @@ export default function workflows( : {}), ...(ref ? { ref } : {}), ...(record.acceptance ? { acceptance: record.acceptance } : {}), + ...(judged.acceptanceWarning + ? { acceptanceWarning: judged.acceptanceWarning } + : {}), }; } @@ -2131,6 +2136,9 @@ export default function workflows( : {}), ...(ref ? { ref } : {}), ...(acceptance ? { acceptance } : {}), + ...(judged.acceptanceWarning + ? { acceptanceWarning: judged.acceptanceWarning } + : {}), ...(record.error !== undefined ? { error: record.error } : {}), }; } finally { diff --git a/extensions/workflows/model.ts b/extensions/workflows/model.ts index dcb4f102..2de7ea3a 100644 --- a/extensions/workflows/model.ts +++ b/extensions/workflows/model.ts @@ -123,7 +123,7 @@ export interface AgentRecord { usage: AgentUsage; /** Replayed from a prior run's journal instead of actually executed. */ replayed?: boolean; - /** Explicit caller-supplied acceptance result; never inferred from role/task. */ + /** Deprecated child self-attestation; never runtime-observed evidence. */ acceptance?: AcceptanceLedger; /** Branch of the isolated worktree this agent ran in, when it holds commits. */ worktreeBranch?: string; diff --git a/extensions/workflows/prompt.ts b/extensions/workflows/prompt.ts index a56afddc..e4d457a0 100644 --- a/extensions/workflows/prompt.ts +++ b/extensions/workflows/prompt.ts @@ -56,7 +56,7 @@ export const WORKFLOW_TOOL_DESCRIPTION = [ "Interactive sessions launch in the background by default and deliver completion later. Set wait: true only when this tool call must return the final result inline.", "Derive fan-out from independent verifiable work items and task difficulty. Concurrency is a runtime ceiling, not a target or the total-call limit; user cost, count, model, and effort constraints take precedence.", "For concurrent writers use isolation: 'worktree' and tell each agent to commit. Read-only work should normally stay in the shared checkout.", - "Read the workflows Skill before a nontrivial script; it covers the restricted sandbox, full DSL, acceptance, result refs, replay, background lifecycle, limits, and examples.", + "Read the workflows Skill before a nontrivial script; it covers the restricted sandbox, full DSL, result refs, replay, background lifecycle, limits, and examples.", ].join("\n"); /** Adds workflow orchestration primitives and background execution to the model's tool prompt. */ @@ -157,7 +157,9 @@ export function buildWorkflowResultMessage( : "running"; lines.push( `- [${agent.label}]${agent.phase ? ` (${agent.phase})` : ""} ${status}` + - (agent.acceptance ? ` · acceptance ${agent.acceptance.status}` : "") + + (agent.acceptance + ? ` · deprecated model self-attestation ${agent.acceptance.status}` + : "") + (agent.error ? ` — ${agent.error}` : ""), ); } diff --git a/skills/workflows/REFERENCE.md b/skills/workflows/REFERENCE.md index 96979a88..82a26273 100644 --- a/skills/workflows/REFERENCE.md +++ b/skills/workflows/REFERENCE.md @@ -12,13 +12,13 @@ The `workflow` script is an async JavaScript function body executed in a restric ## Agent calls -`await agent(prompt, options)` runs one child and always resolves to `{ ok, output, structured?, ref?, acceptance?, error? }`. Check `ok` before reading output. Children receive normal trust-aware resources but cannot recursively orchestrate or ask the user. +`await agent(prompt, options)` runs one child and always resolves to `{ ok, output, structured?, ref?, acceptance?, acceptanceWarning?, error? }`. Check `ok` before reading output. Children receive normal trust-aware resources but cannot recursively orchestrate or ask the user. -Useful options include `agent_type`, `label`, `phase`, `schema`, `acceptance`, `model`, `provider`, `effort`, `isolation`, `operator`, and `inputs`. +Useful options include `agent_type`, `label`, `phase`, `schema`, `model`, `provider`, `effort`, `isolation`, `operator`, and `inputs`. The legacy `acceptance` option remains readable only during the 0.x migration window described below. - Prefer a matching `agent_type`. Model precedence is explicit model/provider, type file, configured built-in role, then parent. Effort precedence is explicit effort, type default, then parent. - `schema` validates structured output. Use it whenever later workflow logic branches on fields. -- `acceptance: { criteria: [{ id, description, requiredEvidence?: string[] }] }` requires the same child to return an evidence ledger. An invocation accepts 1–32 criteria; each criterion has a 1–500 character human-readable `description` and an optional `requiredEvidence` array of at most 16 concise string labels (up to 120 characters each). The child must return exact matching labels in `acceptance.criteria[].evidence`. A criterion without `requiredEvidence` is an attestation of its description, not an evidence-backed gate; criteria that need evidence-backed acceptance must declare the required labels. Missing, malformed, or rejected criteria make `ok:false` while preserving output and evidence. +- `acceptance` is deprecated since OpenPI 0.5 and scheduled for removal in 1.0. Compatibility calls still return the child-authored ledger with `authority: "model-self-attestation"` and a migration warning, but it never determines `ok`. Use ordinary `schema` for findings, then let the parent evaluate them alongside runtime-observed exit codes, test receipts, file fingerprints, and tool results. Old DSL, journals, and artifacts remain readable during 0.x. - `operator: "name"` reuses one in-memory child Session for serialized follow-ups inside the same run. Its model, role/tools, effort, structured mode, and cwd are frozen by the first activation. Operators cannot use per-call worktrees or replay, and do not survive restarts. - `inputs: [ref, ...]` accepts successful opaque refs from the same workflow run only. Each conclusion is bounded to 16 KiB and total injected input to 48 KiB. The total budget is fairly distributed, so a large fan-out cannot starve later results merely because of order; partial projections are labeled. Full successful child results remain in the run's `agent-results/` artifacts. Inputs are marked as untrusted data; the resulting graph is observability, not scheduling authority. - Fair projection preserves the head and tail of every partial result and names its run-relative `agent-results/agent-N.json` audit artifact. That path is provenance for the parent/operator, not a child-readable handle. Fair presence is not proof of full evidence coverage: for large fan-out, group source refs into local Report agents, then pass only their refs to a global Report. The workflow script—not Runtime—must state planned, selected, covered, failed, and deferred counts. diff --git a/skills/workflows/SKILL.md b/skills/workflows/SKILL.md index 9091afb2..6c7bb056 100644 --- a/skills/workflows/SKILL.md +++ b/skills/workflows/SKILL.md @@ -1,6 +1,6 @@ --- name: workflows -description: Orchestrates multi-agent work with OpenPI's inline JavaScript Workflow DSL. Use when a task needs multi-phase fan-out, pipelines, barriers, structured handoffs, acceptance evidence, or resumable background orchestration. +description: Orchestrates multi-agent work with OpenPI's inline JavaScript Workflow DSL. Use when a task needs multi-phase fan-out, pipelines, barriers, structured handoffs, or resumable background orchestration. --- # Workflows diff --git a/tests/extensions/workflows/acceptance.test.ts b/tests/extensions/workflows/acceptance.test.ts index 878e242f..1e8adc51 100644 --- a/tests/extensions/workflows/acceptance.test.ts +++ b/tests/extensions/workflows/acceptance.test.ts @@ -58,7 +58,7 @@ test("rejected, missing, malformed, and missing evidence remain distinct", () => ); }); -test("acceptance controls ok without hiding an underlying agent error", () => { +test("deprecated self-attestation never controls runtime ok", () => { const accepted = applyAcceptance({ contract, agentOk: true, @@ -72,6 +72,28 @@ test("acceptance controls ok without hiding an underlying agent error", () => { }, }); assert.equal(accepted.ok, true); + assert.equal(accepted.ledger?.authority, "model-self-attestation"); + assert.deepEqual(accepted.ledger?.deprecated, { + since: "0.5", + removal: "1.0", + }); + assert.match(accepted.acceptanceWarning ?? "", /does not determine ok/); + + const rejected = applyAcceptance({ + contract, + agentOk: true, + structured: { + acceptance: { + criteria: [ + { id: "tests", status: "rejected", evidence: ["command"] }, + { id: "scope", status: "accepted", evidence: [] }, + ], + }, + }, + }); + assert.equal(rejected.ok, true); + assert.equal(rejected.ledger?.status, "rejected"); + assert.equal(rejected.error, undefined); const failed = applyAcceptance({ contract, @@ -80,7 +102,8 @@ test("acceptance controls ok without hiding an underlying agent error", () => { structured: undefined, }); assert.equal(failed.ok, false); - assert.match(failed.error ?? "", /provider failed; Acceptance missing/); + assert.equal(failed.error, "provider failed"); + assert.equal(failed.ledger?.status, "missing"); }); test("contract validation rejects duplicate or unsafe identifiers", () => { diff --git a/tests/extensions/workflows/execute.e2e.test.ts b/tests/extensions/workflows/execute.e2e.test.ts index 7db782a3..6aa192e7 100644 --- a/tests/extensions/workflows/execute.e2e.test.ts +++ b/tests/extensions/workflows/execute.e2e.test.ts @@ -1146,7 +1146,7 @@ async function runTamperedAcceptanceReplay( const script = `export const meta = { name: "acceptance-replay-${fixtureId}" };\n` + 'const r = await agent("verify the fixture", { agent_type: "reviewer", acceptance: { criteria: [{ id: "tests", description: "Focused tests pass", requiredEvidence: ["command"] }] } });\n' + - "return { ok: r.ok, error: r.error };"; + "return { ok: r.ok, error: r.error, acceptanceWarning: r.acceptanceWarning };"; try { const first = (await workflow.execute( @@ -1195,50 +1195,46 @@ const replayAcceptanceVerdicts = [ "malformed", ] as const satisfies readonly ReplayAcceptanceVerdict[]; -// Keep the three projections independent: the script-visible result, the -// persisted lifecycle record, and success-only filesystem side effects. -test("replay acceptance verdicts preserve the script return contract", async () => { +// Keep the three projections independent: the deprecated model judgment, the +// runtime execution record, and success-only filesystem side effects. +test("replay self-attestation verdicts do not override runtime success", async () => { for (const verdict of replayAcceptanceVerdicts) { const fixture = await runTamperedAcceptanceReplay(verdict); const projected = fixture.resumed.content .map((entry) => entry.text) .join("\n"); - assert.match(projected, /"ok"\s*:\s*false/); - assert.match(projected, new RegExp(`Acceptance ${verdict}`)); - if (verdict === "malformed") { - const error = projected.match(/Acceptance malformed[^\n]*/)?.[0] ?? ""; - assert.ok([...error].length <= 2_000); - assert.ok(!/[\u0000-\u001f\u007f-\u009f]/.test(error)); - } + assert.match(projected, /"ok"\s*:\s*true/); + assert.doesNotMatch(projected, /"error"/); + assert.match(projected, /model self-attestation/); } }); -test("replay acceptance verdicts persist fail-closed runtime records", async () => { +test("replay self-attestation remains distinct from runtime records", async () => { for (const verdict of replayAcceptanceVerdicts) { const { agents, sessionCreations } = await runTamperedAcceptanceReplay(verdict); assert.equal(sessionCreations, 1); assert.equal(agents.length, 1); - assert.equal(agents[0]?.state, "error"); - assert.equal(agents[0]?.replayed, undefined); - assert.equal(agents[0]?.invocation?.admissionState, "rejected"); + assert.equal(agents[0]?.state, "done"); + assert.equal(agents[0]?.replayed, true); + assert.equal(agents[0]?.invocation?.admissionState, "replayed"); assert.equal(agents[0]?.invocation?.executionState, "settled"); - assert.equal(agents[0]?.invocation?.outcome, "error"); + assert.equal(agents[0]?.invocation?.outcome, "success"); assert.equal(agents[0]?.acceptance?.status, verdict); - assert.match(String(agents[0]?.error), new RegExp(`Acceptance ${verdict}`)); - assert.equal(agents[0]?.resultArtifact, undefined); - assert.equal(agents[0]?.resultRef, undefined); + assert.equal(agents[0]?.error, undefined); + assert.equal(typeof agents[0]?.resultArtifact, "string"); + assert.equal(typeof agents[0]?.resultRef, "string"); } }); -test("replay acceptance verdicts do not create success filesystem side effects", async () => { +test("replayed runtime successes retain artifacts despite self-attestation", async () => { for (const verdict of replayAcceptanceVerdicts) { const { runDir } = await runTamperedAcceptanceReplay(verdict); assert.equal( existsSync(join(runDir, "agent-results/agent-0001.json")), - false, + true, ); - assert.equal(existsSync(join(runDir, "journal.json")), false); + assert.equal(existsSync(join(runDir, "journal.json")), true); } }); diff --git a/tests/extensions/workflows/prompt.test.ts b/tests/extensions/workflows/prompt.test.ts index ccf880cb..94a8dc1c 100644 --- a/tests/extensions/workflows/prompt.test.ts +++ b/tests/extensions/workflows/prompt.test.ts @@ -298,7 +298,7 @@ test("result message names where isolated work ended up", () => { assert.doesNotMatch(msg, /\[plain\].*worktree/); }); -test("result message surfaces explicit acceptance state", () => { +test("result message labels deprecated acceptance as model self-attestation", () => { const msg = buildWorkflowResultMessage( details([ agentRecord({ @@ -313,7 +313,7 @@ test("result message surfaces explicit acceptance state", () => { ]), "/tmp/wf_abc123", ); - assert.match(msg, /acceptance rejected/); + assert.match(msg, /deprecated model self-attestation rejected/); }); test("result message stays quiet when nothing was isolated", () => { @@ -416,7 +416,7 @@ test("the resident workflow prompt stays compact while the Skill carries the ful assert.match(skill, /^---\r?\nname: workflows\r?\n/); assert.match(skill, /Use when .*multi-phase/i); assert.match(reference, /operator/); - assert.match(reference, /acceptance/); + assert.match(reference, /result refs/); assert.match(reference, /resume_from_run_id/); assert.match(reference, /same workflow run/i); assert.match(examples, /reliability-review/);