@piagent/platform 1.45.0 → 1.46.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +5 -3
- package/README.vi.md +5 -3
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/distribution-standard.md +6 -6
- package/docs/en/architecture.md +1 -1
- package/docs/harness-conformance.md +22 -5
- package/docs/languages.json +4 -4
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +2 -0
- package/docs/release-install-policy.md +2 -2
- package/docs/team-onboarding.md +2 -2
- package/docs/vi/architecture.md +1 -1
- package/docs-site/architecture.html +1 -1
- package/docs-site/assets/search-en.json +1 -1
- package/docs-site/assets/search-vi.json +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/context-engine.html +1 -1
- package/docs-site/ecosystem.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/ecosystem.html +1 -1
- package/docs-site/en/index.html +15 -15
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +1 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/update.html +3 -3
- package/docs-site/en/whats-new.html +55 -29
- package/docs-site/en/windows.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +15 -15
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/releases.json +83 -0
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/update.html +3 -3
- package/docs-site/whats-new.html +55 -29
- package/docs-site/windows.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
- package/package.json +1 -1
- package/packages/piagent-core/benchmark/benchmark-bootstrap.js +3 -1
- package/packages/piagent-core/extensions/agent-home.d.ts +4 -0
- package/packages/piagent-core/extensions/agent-home.js +52 -0
- package/packages/piagent-core/extensions/piagent-guard.ts +5 -1
- package/packages/piagent-core/managed/copies.mjs +61 -0
- package/packages/piagent-core/managed/grant-share.mjs +9 -1
- package/packages/piagent-core/managed/helper-roles.mjs +5 -3
- package/packages/piagent-core/managed/helper-run.mjs +120 -52
- package/packages/piagent-core/managed/helper-settlement.mjs +5 -1
- package/packages/piagent-core/managed/ledger.mjs +187 -0
- package/packages/piagent-core/managed/objections.mjs +49 -27
- package/packages/piagent-core/managed/patch-snapshot.mjs +9 -0
- package/packages/piagent-core/managed/session.mjs +14 -14
- package/packages/piagent-core/managed/toolchain.mjs +7 -3
- package/packages/piagent-core/managed/workflow.mjs +32 -26
- package/packages/piagent-core/mcp/mcp-config-layers.js +4 -1
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/runtime/managed-failure.mjs +8 -0
- package/packages/piagent-webui/extension/piagent-webui.ts +2 -0
- package/packages/piagent-webui/gateway/gateway-service.ts +4 -1
- package/packages/piagent-webui/gateway/pi-host.ts +2 -2
- package/packages/piagent-webui/ownership/profile-state.ts +10 -1
- package/packages/piagent-webui/server/benchmark-release-monitor.ts +2 -1
- package/scripts/benchmark-runner-support.mjs +2 -1
- package/scripts/company-terminal.mjs +19 -1
- package/scripts/dashboard-update-job.mjs +4 -2
- package/scripts/install-global.sh +113 -5
- package/scripts/model-scope-prune.mjs +2 -1
- package/scripts/personal-pi-config.mjs +2 -1
- package/scripts/personal-pi-restore.mjs +184 -0
- package/scripts/pi-usage-history.mjs +2 -1
- package/scripts/piagent-cli.mjs +49 -9
- package/scripts/piagent-dashboard.mjs +5 -3
- package/scripts/setup.sh +2 -6
- package/scripts/team-doctor.sh +11 -5
- package/scripts/uninstall-global.sh +17 -2
- package/scripts/update-global.mjs +5 -2
|
@@ -7,12 +7,14 @@ import { fenced, HELPER_UNTRUSTED_RULE } from './untrusted.mjs';
|
|
|
7
7
|
import { jobTitle } from './call-purpose.mjs';
|
|
8
8
|
import { ManagedToolBoundary } from './tool-boundary.mjs';
|
|
9
9
|
import { managedResourceLoader } from './resource-loader.mjs';
|
|
10
|
-
import { shareGrant, releaseGrant } from './grant-share.mjs';
|
|
10
|
+
import { shareGrant, releaseGrant, slotArgs, baseRole } from './grant-share.mjs';
|
|
11
11
|
import { describeFailure, failureCode, failureText } from '../runtime/managed-failure.mjs';
|
|
12
|
-
import { reviewParts, mergeReviews, REVIEW_PARALLEL, emptySnapshot, readBranchBase, readPatchDigest, readPatchSnapshot, resolveReviewBase } from './patch-snapshot.mjs';
|
|
13
|
-
import { reviewFindings, briefIssues } from './workflow.mjs';
|
|
12
|
+
import { reviewParts, focusParts, mergeReviews, REVIEW_PARALLEL, emptySnapshot, readBranchBase, readPatchDigest, readPatchSnapshot, resolveReviewBase } from './patch-snapshot.mjs';
|
|
13
|
+
import { reviewFindings, briefIssues, verifyVerdicts } from './workflow.mjs';
|
|
14
14
|
import { currentWorkflow, helperWorkflowPrompt } from './workflow-context.mjs';
|
|
15
15
|
import { anchorText, relayObjection, recordVerdicts } from './objections.mjs';
|
|
16
|
+
import { recordFindings } from './ledger.mjs';
|
|
17
|
+
import { copyNote, mergeCopies } from './copies.mjs';
|
|
16
18
|
import { HELPER_CALLS, HELPER_SETUP, READ_TOOLS, countedCheck } from './helper-roles.mjs';
|
|
17
19
|
import { trackToolFailures } from './tool-failures.mjs';
|
|
18
20
|
import { helperMemoryTools, renewer } from './memory.mjs';
|
|
@@ -43,14 +45,16 @@ async function reviewSnapshot(managed, harness, since) {
|
|
|
43
45
|
return branch ? Object.assign(await readPatchSnapshot(managed.boundary, null, branch.base), { from: { kind: 'branch', ref: branch.ref, base: branch.base } }) : snapshot;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
export async function runHelper(managed, role, task, signal, harness, { verifyGrant, patiently, title, since }) {
|
|
47
|
-
const snapshot = role === 'review' ? await reviewSnapshot(managed, harness, since) : null
|
|
48
|
+
export async function runHelper(managed, role, task, signal, harness, { verifyGrant, patiently, title, since, fan = null }) {
|
|
49
|
+
const snapshot = role === 'review' ? await reviewSnapshot(managed, harness, since) : null;
|
|
50
|
+
// The harness's re-review after a fix reads only the parts holding a file changed since (reviewFocus).
|
|
51
|
+
const parts = snapshot ? focusParts(reviewParts(snapshot), harness && !since ? managed.reviewFocus : null) : null;
|
|
48
52
|
// A helper whose company model account is out of usage, or resting after
|
|
49
53
|
// a refusal, starts once more: Studio then gives the new helper the next
|
|
50
54
|
// model of its role that serves (an auto role's list). Helpers read and
|
|
51
55
|
// run checks only, so starting over repeats no change.
|
|
52
56
|
for (let attempt = 1; ; attempt += 1) {
|
|
53
|
-
try { return await helperAttempt(managed, role, task, signal, harness, snapshot, parts, { verifyGrant, patiently, title }); }
|
|
57
|
+
try { return await helperAttempt(managed, role, task, signal, harness, snapshot, parts, { verifyGrant, patiently, title, fan }); }
|
|
54
58
|
catch (error) {
|
|
55
59
|
if (attempt >= HELPER_ATTEMPTS || signal?.aborted || !HELPER_SWITCH.has(failureCode(error?.message)) || (managed.helperCalls.get(role) ?? 0) >= HELPER_CALLS) throw error;
|
|
56
60
|
}
|
|
@@ -59,13 +63,62 @@ export async function runHelper(managed, role, task, signal, harness, { verifyGr
|
|
|
59
63
|
// A helper's role closes through the same wait as the run's close: a passing
|
|
60
64
|
// failure of Agent Watch's way to Studio is asked again. A close that still
|
|
61
65
|
// fails leaves the helper's answer standing: the role closes with the run,
|
|
62
|
-
// and the next helper of the role closes it first.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
// and the next helper of the role closes it first. `key`: the role, or
|
|
67
|
+
// role#slot for a helper the member asked for beside it (grant-share.mjs).
|
|
68
|
+
export async function closeRole(managed, key) {
|
|
69
|
+
try { await viaLink(managed.studioLink, () => managed.broker.request('close', slotArgs(key))); managed.unclosed?.delete(key); }
|
|
70
|
+
catch { (managed.unclosed ??= new Set()).add(key); }
|
|
66
71
|
}
|
|
67
|
-
|
|
68
|
-
|
|
72
|
+
// Helpers the member asked for run on grants of their own (slots) when
|
|
73
|
+
// Studio and Agent Watch hold several of a role at once; otherwise side by
|
|
74
|
+
// side on the role's one grant, where Studio takes their requests in turn.
|
|
75
|
+
const slotsReady = managed => (managed.manifest?.broker_features ?? []).includes('helper-slots');
|
|
76
|
+
|
|
77
|
+
async function helperAttempt(managed, role, task, signal, harness, snapshot, parts, { verifyGrant, patiently, title, fan }) {
|
|
78
|
+
const setup = HELPER_SETUP[role], opts = { verifyGrant, patiently, title };
|
|
79
|
+
let result;
|
|
80
|
+
if (!fan) result = await answerUnder(managed, role, task, signal, harness, snapshot, parts, opts, ['']);
|
|
81
|
+
else if (!slotsReady(managed)) result = await answerUnder(managed, role, task, signal, harness, snapshot, parts, opts, Array.from({ length: fan.n }, (_, i) => copyNote(role, i, fan)));
|
|
82
|
+
else {
|
|
83
|
+
// One grant per helper, all at once; the first that fails stops the others.
|
|
84
|
+
const stop = new AbortController(), both = AbortSignal.any([stop.signal, ...(signal ? [signal] : [])]);
|
|
85
|
+
let results;
|
|
86
|
+
try { results = await Promise.all(Array.from({ length: fan.n }, (_, i) => answerUnder(managed, i ? `${role}#${i + 1}` : role, task, both, harness, snapshot, parts, opts, [copyNote(role, i, fan)]).catch(error => { stop.abort(); throw error; }))); }
|
|
87
|
+
catch (error) { throw signal?.aborted ? Error('managed-helper-cancelled') : error; }
|
|
88
|
+
const merged = mergeCopies(role, setup.label, results, { findingsOf: reviewFindings, issuesOf: briefIssues, verdictsOf: verifyVerdicts });
|
|
89
|
+
result = { ...results[0], reply: merged.reply, usage: merged.usage, review: merged.findings ? merged : null, stale: results.some(r => r.stale), report: null };
|
|
90
|
+
}
|
|
91
|
+
const { reply, usage, review, stale, from, grant } = result;
|
|
92
|
+
const details = { role, runID: grant.run_id, tokens: usage, patchDigest: snapshot?.digest, stale, ...(fan ? { helpers: fan.n, ...(slotsReady(managed) ? { slots: true } : {}) } : {}), ...(from ? { since: { kind: from.kind, ref: from.ref, base: from.base } } : {}) };
|
|
93
|
+
if (snapshot) {
|
|
94
|
+
// Severity-graded findings decide whether the harness sends the agent back.
|
|
95
|
+
const findings = review ? review.findings : reviewFindings(reply);
|
|
96
|
+
Object.assign(details, { parsed: review ? review.parsed : !!findings, findings: (findings ?? []).slice(0, 40), blocking: findings?.filter(f => f.severity === 'blocking').length ?? 0 });
|
|
97
|
+
if (review) Object.assign(details, { parts: parts.parts.length, unread: parts.omitted.map(o => o.path).slice(0, 50) });
|
|
98
|
+
if (managed.run) { managed.run.reviews += 1; managed.run.blocking += details.blocking; }
|
|
99
|
+
// A review of earlier commits is not the review of this turn's change;
|
|
100
|
+
// what each file held then tells a later re-review what changed since.
|
|
101
|
+
if (!from) { managed.review = details; if (!stale) { recordFindings(managed, findings); managed.reviewFiles = await managed.changedFiles?.(); } }
|
|
102
|
+
managed.session.sessionManager.appendCustomEntry('agent-watch-review', details);
|
|
103
|
+
}
|
|
104
|
+
const verdicts = role === 'verify' ? recordVerdicts(managed, reply, harness, details) : '';
|
|
105
|
+
const report = result.report ?? answeredSettlement(role, reply, details, managed.helperSpecs?.get(role) ?? {}); details.outcome = report.outcome;
|
|
106
|
+
await managed.session.sendCustomMessage({ customType: 'agent-watch-helper-receipt', display: true,
|
|
107
|
+
content: `${setup.label}: ${usage.toLocaleString('en-US')} tokens${verdicts}${fan ? ` · ${fan.n} helpers` : review ? ` · ${parts.parts.length} parts` : ''}${snapshot ? (stale ? ' · review is stale' : ' · patch ' + snapshot.digest.slice(0, 12)) : ''} · ${OUTCOME_WORDS[report.outcome]}.`, details }, { triggerTurn: false });
|
|
108
|
+
// An objection to the brief comes with the answer; a stale review has none to act on.
|
|
109
|
+
const objection = stale ? '' : await relayObjection(managed, role, reply, harness);
|
|
110
|
+
const ending = `Outcome: ${report.outcome}${report.outcome === 'unverifiable' ? ` (${report.summary})` : ''}.\n\n`;
|
|
111
|
+
// The helper read untrusted content: its answer is data for the main agent.
|
|
112
|
+
return { content: [{ type: 'text', text: ending + (stale ? 'Review is stale: the patch changed during review. Ask for another review only after fixing blocking findings; under a required review the harness reviews the final patch.' : fenced(`${setup.label} helper's answer`, reply) + objection) }], details };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// One grant of the role (`key`: the role, or role#slot) and the helper
|
|
116
|
+
// sessions that answer under it, one per note (a copy's place among the
|
|
117
|
+
// helpers the member asked for). Its settlement goes to Studio and the grant
|
|
118
|
+
// closes before the answer is used.
|
|
119
|
+
async function answerUnder(managed, key, task, signal, harness, snapshot, parts, { verifyGrant, patiently, title }, notes) {
|
|
120
|
+
const role = baseRole(key);
|
|
121
|
+
if (managed.unclosed?.has(key)) await closeRole(managed, key);
|
|
69
122
|
// A long local tool step may outlive the main lease. Refresh authority
|
|
70
123
|
// before asking for a child; the broker serializes concurrent renewals,
|
|
71
124
|
// and main's own searches running beside it share managed renew.
|
|
@@ -75,18 +128,18 @@ async function helperAttempt(managed, role, task, signal, harness, snapshot, par
|
|
|
75
128
|
try {
|
|
76
129
|
const parent = await share.grant;
|
|
77
130
|
verifyGrant(parent, managed.manifest, 'main'); managed.grant = parent;
|
|
78
|
-
grant = await patiently(() => managed.broker.request('child',
|
|
131
|
+
grant = await patiently(() => managed.broker.request('child', slotArgs(key)), 2);
|
|
79
132
|
} finally { releaseGrant(managed, 'main', share); }
|
|
80
|
-
managed.helperCalls.set(role, (managed.helperCalls.get(role) ?? 0) +
|
|
81
|
-
try { verifyGrant(grant, managed.manifest,
|
|
82
|
-
catch (error) { try { await managed.broker.request('close',
|
|
133
|
+
managed.helperCalls.set(role, (managed.helperCalls.get(role) ?? 0) + notes.length); // each helper the member asked for counts
|
|
134
|
+
try { verifyGrant(grant, managed.manifest, key); }
|
|
135
|
+
catch (error) { try { await managed.broker.request('close', slotArgs(key)); } catch { /* closes with the run */ } throw error; }
|
|
83
136
|
} catch (error) { throw Error(`managed-helper-failed: ${describeFailure(role, failureText(error))}`); }
|
|
84
137
|
let boundary, report = null;
|
|
85
|
-
const sessions = new Set(), spec = managed.helperSpecs?.get(role) ?? {};
|
|
86
|
-
const renew = renewer(managed,
|
|
138
|
+
const sessions = new Set(), watched = new Set(), spec = managed.helperSpecs?.get(role) ?? {};
|
|
139
|
+
const renew = renewer(managed, key, g => verifyGrant(g, managed.manifest, key));
|
|
87
140
|
try {
|
|
88
141
|
const runtime = await managed.api.ModelRuntime.create({ credentials: new managed.ai.InMemoryCredentialStore(), modelsPath: null, refreshOnCreate: false, allowModelNetwork: false });
|
|
89
|
-
const model = managed.installModel(runtime, grant); managed.wrapStreams(runtime,
|
|
142
|
+
const model = managed.installModel(runtime, grant); managed.wrapStreams(runtime, key);
|
|
90
143
|
// Studio's logs name the job by its title (the brief's first line without one).
|
|
91
144
|
(managed.jobTitles ??= new WeakMap()).set(runtime, jobTitle(task, title));
|
|
92
145
|
const setup = HELPER_SETUP[role], memory = helperMemoryTools(managed, () => grant, renew);
|
|
@@ -95,15 +148,15 @@ async function helperAttempt(managed, role, task, signal, harness, snapshot, par
|
|
|
95
148
|
// One helper session answers the brief (with one part of the patch for a
|
|
96
149
|
// review in parts); its reply and tokens. A helper runs at its role's
|
|
97
150
|
// level from Studio (fixed by the Harness, or its main agent's).
|
|
98
|
-
const ask = async extra => {
|
|
151
|
+
const ask = async (extra, note = '', who = key) => {
|
|
99
152
|
const { session } = await managed.api.createAgentSession({ cwd: managed.cwd, model, modelRuntime: runtime, thinkingLevel: grant.effort || 'off',
|
|
100
153
|
settingsManager: managed.api.SettingsManager.inMemory({ retry: { enabled: false, provider: { maxRetries: 0 } }, cacheWarming: 'off' }),
|
|
101
154
|
sessionManager: managed.api.SessionManager.inMemory(managed.cwd), noTools: 'builtin', tools: [...READ_TOOLS, ...(setup.commands ? ['bash'] : []), ...(setup.web ? ['web_search', 'web_fetch'] : []), ...memory.map(tool => tool.name)],
|
|
102
155
|
customTools: [...boundary.tools(managed.api).filter(tool => READ_TOOLS.includes(tool.name) || setup.commands && tool.name === 'bash').map(tool => countedCheck(managed, tool)),
|
|
103
|
-
...(setup.web ? managed.webTools(runtime,
|
|
104
|
-
sessions.add(session); trackToolFailures(managed, session, role);
|
|
156
|
+
...(setup.web ? managed.webTools(runtime, key) : []), ...memory], resourceLoader: managedResourceLoader(managed.api, { systemPrompt: setup.prompt + HELPER_UNTRUSTED_RULE }) });
|
|
157
|
+
sessions.add(session); trackToolFailures(managed, session, role); watched.add(who); watchHelper(managed, who, session, task, title);
|
|
105
158
|
const abort = () => void session.abort(); signal?.addEventListener('abort', abort, { once: true });
|
|
106
|
-
try { if (signal?.aborted) throw Error('managed-helper-cancelled'); await session.prompt(anchorText(managed.session.sessionManager, harness) + task + helperWorkflowPrompt(currentWorkflow(managed.session.sessionManager)) + checks + extra, { expandPromptTemplates: false }); }
|
|
159
|
+
try { if (signal?.aborted) throw Error('managed-helper-cancelled'); await session.prompt(anchorText(managed.session.sessionManager, harness) + task + note + helperWorkflowPrompt(currentWorkflow(managed.session.sessionManager)) + checks + extra, { expandPromptTemplates: false }); }
|
|
107
160
|
finally { signal?.removeEventListener('abort', abort); }
|
|
108
161
|
const messages = session.messages.filter(m => m.role === 'assistant');
|
|
109
162
|
// The main agent (and the member) learn why a helper failed, not just that it did.
|
|
@@ -112,50 +165,65 @@ async function helperAttempt(managed, role, task, signal, harness, snapshot, par
|
|
|
112
165
|
if (!messages.length || messages.at(-1).stopReason === 'aborted') throw Error(signal?.aborted ? 'managed-helper-cancelled' : 'managed-helper-failed');
|
|
113
166
|
return { reply: messages.map(textContent).join('\n'), usage: messages.reduce((n, m) => n + (m.usage?.totalTokens ?? 0), 0) };
|
|
114
167
|
};
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
({
|
|
118
|
-
reply = reply.slice(0, 16000);
|
|
119
|
-
} else {
|
|
168
|
+
// One helper's answer: the brief (with the whole patch, or the patch in parts for a large review).
|
|
169
|
+
const once = async (note = '', who = key) => {
|
|
170
|
+
if (!parts || parts.parts.length === 1) { const answer = await ask(parts ? `\n${parts.parts[0].text}` : '', note, who); return { ...answer, reply: answer.reply.slice(0, 16000), review: null }; }
|
|
120
171
|
// A large patch: one reviewer per part, REVIEW_PARALLEL at a time (Studio
|
|
121
172
|
// admits one request of a role at a time); the first part that fails
|
|
122
173
|
// stops the others.
|
|
123
174
|
const answers = new Array(parts.parts.length);
|
|
124
175
|
let next = 0;
|
|
125
176
|
const reviewer = async () => {
|
|
126
|
-
while (next < parts.parts.length) { const i = next++; answers[i] = await ask(`\n${parts.parts[i].text}
|
|
177
|
+
while (next < parts.parts.length) { const i = next++; answers[i] = await ask(`\n${parts.parts[i].text}`, note, who); }
|
|
127
178
|
};
|
|
128
179
|
try { await Promise.all(Array.from({ length: Math.min(REVIEW_PARALLEL, parts.parts.length) }, reviewer)); }
|
|
129
180
|
catch (error) { next = parts.parts.length; for (const session of sessions) void session.abort(); throw error; }
|
|
130
|
-
review = mergeReviews(parts, answers, { findingsOf: reviewFindings, issuesOf: briefIssues });
|
|
131
|
-
|
|
181
|
+
const review = mergeReviews(parts, answers, { findingsOf: reviewFindings, issuesOf: briefIssues });
|
|
182
|
+
return { reply: review.reply, usage: review.usage, review };
|
|
183
|
+
};
|
|
184
|
+
let reply, usage, review = null;
|
|
185
|
+
if (notes.length === 1) ({ reply, usage, review } = await once(notes[0]));
|
|
186
|
+
else {
|
|
187
|
+
// Helpers the member asked for on this one grant, each on its own; the first that fails stops the others.
|
|
188
|
+
let answers;
|
|
189
|
+
try { answers = await Promise.all(notes.map((note, i) => once(note, i ? `${role}#${i + 1}` : key))); }
|
|
190
|
+
catch (error) { for (const session of sessions) void session.abort(); throw error; }
|
|
191
|
+
const merged = mergeCopies(role, setup.label, answers, { findingsOf: reviewFindings, issuesOf: briefIssues, verdictsOf: verifyVerdicts });
|
|
192
|
+
({ reply, usage } = merged); review = merged.findings ? merged : null;
|
|
132
193
|
}
|
|
133
194
|
const from = snapshot?.from ?? null;
|
|
134
195
|
const stale = snapshot ? (await (from ? readPatchDigest(managed.boundary, null, from.base) : managed.patchDigest())) !== snapshot.digest : false;
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const findings = review ? review.findings : reviewFindings(reply);
|
|
139
|
-
Object.assign(details, { parsed: review ? review.parsed : !!findings, findings: (findings ?? []).slice(0, 40), blocking: findings?.filter(f => f.severity === 'blocking').length ?? 0 });
|
|
140
|
-
if (review) Object.assign(details, { parts: parts.parts.length, unread: parts.omitted.map(o => o.path).slice(0, 50) });
|
|
141
|
-
if (managed.run) { managed.run.reviews += 1; managed.run.blocking += details.blocking; }
|
|
142
|
-
// A review of earlier commits is not the review of this turn's change.
|
|
143
|
-
if (!from) managed.review = details;
|
|
144
|
-
managed.session.sessionManager.appendCustomEntry('agent-watch-review', details);
|
|
145
|
-
}
|
|
146
|
-
const verdicts = role === 'verify' ? recordVerdicts(managed.run, reply, harness, details) : '';
|
|
147
|
-
report = answeredSettlement(role, reply, details, spec); details.outcome = report.outcome;
|
|
148
|
-
await managed.session.sendCustomMessage({ customType: 'agent-watch-helper-receipt', display: true,
|
|
149
|
-
content: `${setup.label}: ${usage.toLocaleString('en-US')} tokens${verdicts}${review ? ` · ${parts.parts.length} parts` : ''}${snapshot ? (stale ? ' · review is stale' : ' · patch ' + snapshot.digest.slice(0, 12)) : ''} · ${OUTCOME_WORDS[report.outcome]}.`, details }, { triggerTurn: false });
|
|
150
|
-
// An objection to the brief comes with the answer; a stale review has none to act on.
|
|
151
|
-
const objection = stale ? '' : await relayObjection(managed, role, reply, harness);
|
|
152
|
-
const ending = `Outcome: ${report.outcome}${report.outcome === 'unverifiable' ? ` (${report.summary})` : ''}.\n\n`;
|
|
153
|
-
// The helper read untrusted content: its answer is data for the main agent.
|
|
154
|
-
return { content: [{ type: 'text', text: ending + (stale ? 'Review is stale: the patch changed during review. Obtain a new review for the current patch.' : fenced(`${setup.label} helper's answer`, reply) + objection) }], details };
|
|
196
|
+
const findings = snapshot ? (review ? review.findings : reviewFindings(reply)) : null;
|
|
197
|
+
report = answeredSettlement(role, reply, snapshot ? { stale, parsed: review ? review.parsed : !!findings, findings: findings ?? [] } : {}, spec);
|
|
198
|
+
return { reply, usage, review, stale, from, grant, report };
|
|
155
199
|
} catch (error) { report = failedSettlement(role, error, signal, spec); throw error; }
|
|
156
200
|
finally {
|
|
157
201
|
for (const session of sessions) session.dispose(); await boundary?.dispose();
|
|
158
202
|
await settleHelper(managed, renew.lease, report); // with the role's current token, before it closes
|
|
159
|
-
await closeRole(managed,
|
|
203
|
+
await closeRole(managed, key);
|
|
204
|
+
for (const who of watched) helperState(managed, who, null);
|
|
160
205
|
}
|
|
161
206
|
}
|
|
207
|
+
|
|
208
|
+
// The helpers running now and what each is doing, told to the session's
|
|
209
|
+
// listeners (the terminal's subagent panel, the WebUI) as it changes.
|
|
210
|
+
export function helperState(managed, key, state) {
|
|
211
|
+
const states = managed.helperStates ??= new Map();
|
|
212
|
+
if (state) states.set(key, state); else states.delete(key);
|
|
213
|
+
managed.notify?.({ type: 'managed_helper_state', helpers: [...states].map(([k, s]) => ({ key: k, role: baseRole(k), slot: slotArgs(k).slot ?? 1, ...s })) });
|
|
214
|
+
}
|
|
215
|
+
// What a helper is doing, for the terminal's subagent panel: the job and its
|
|
216
|
+
// latest tool step, told to listeners as it changes (company-terminal.mjs).
|
|
217
|
+
function watchHelper(managed, key, session, task, title) {
|
|
218
|
+
const job = String(title || task.split('\n').find(line => line.trim()) || '').trim().slice(0, 120);
|
|
219
|
+
helperState(managed, key, { job, step: 'đọc brief' });
|
|
220
|
+
session.subscribe?.(event => {
|
|
221
|
+
if (event?.type !== 'tool_execution_start') return;
|
|
222
|
+
helperState(managed, key, { job, step: stepText(event.toolName, event.args) });
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
const STEP = { read: 'đọc', grep: 'tìm', find: 'tìm file', ls: 'xem thư mục', bash: 'chạy', web_search: 'tìm trên web', web_fetch: 'đọc trang', memory_search: 'tra team memory', memory_ask: 'hỏi team memory' };
|
|
226
|
+
export function stepText(tool, args = {}) {
|
|
227
|
+
const what = args?.path ?? args?.pattern ?? args?.command ?? args?.query ?? args?.url ?? '';
|
|
228
|
+
return `${STEP[tool] ?? tool}${what ? ' ' + String(what).replace(/\s+/g, ' ').slice(0, 80) : ''}`;
|
|
229
|
+
}
|
|
@@ -24,7 +24,11 @@ export function delegateParameters() {
|
|
|
24
24
|
title: { type: 'string', maxLength: 120, description: 'One line naming the job, for the company logs (for example "Find where login tokens are stored").' },
|
|
25
25
|
task: { type: 'string', minLength: 1, maxLength: 12000 },
|
|
26
26
|
...Object.fromEntries(Object.entries(SPEC_FIELDS).map(([name, description]) => [name, field(description)])),
|
|
27
|
-
since: { type: 'string', maxLength: 200, description: 'Review only: the commit, branch or HEAD~N the work to review started from (work committed in earlier turns).' }
|
|
27
|
+
since: { type: 'string', maxLength: 200, description: 'Review only: the commit, branch or HEAD~N the work to review started from (work committed in earlier turns).' },
|
|
28
|
+
// Several helpers of the role, only at the member's request (copies.mjs).
|
|
29
|
+
copies: { type: 'integer', minimum: 2, maximum: 3, description: 'Only when the member asked for several helpers of this role: how many (2 or 3) run side by side on this brief. Never on your own.' },
|
|
30
|
+
split: { type: 'array', minItems: 2, maxItems: 3, items: { type: 'string', minLength: 1, maxLength: 500 }, description: 'Instead of copies: one line per helper naming its share of the work, when the member asked for several and the work divides.' },
|
|
31
|
+
member_words: { type: 'string', maxLength: 300, description: 'With copies or split: the member\'s own words, verbatim, that ask for that many helpers.' } },
|
|
28
32
|
required: ['role', 'task'], additionalProperties: false };
|
|
29
33
|
}
|
|
30
34
|
export const SPEC_PROMPT = ' Give a helper target and acceptance at least (also change, constraints and ownership when they matter): it answers against them, and its ending is recorded with them.';
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { briefIssues } from './workflow.mjs';
|
|
4
|
+
|
|
5
|
+
// The objection ledger: what a helper holds against the main agent's work in
|
|
6
|
+
// one run (one member message), and what the main agent decided about it.
|
|
7
|
+
// A helper's objection to its brief, a review finding and a failed verify
|
|
8
|
+
// claim are one kind of item: open until the main agent resolves it with
|
|
9
|
+
// resolve_objection (accepted with what it changed, or rejected with what it
|
|
10
|
+
// checked and why), and a rejection of a required item goes back to the
|
|
11
|
+
// helper once; a disagreement that remains goes to the member. The run's
|
|
12
|
+
// items and decisions are one session entry when the run ends.
|
|
13
|
+
export const LEDGER_ENTRY = 'agent-watch-objections';
|
|
14
|
+
// A brief objection that blocks file changes until it is resolved: the brief
|
|
15
|
+
// rests on a premise the code contradicts, or on a reading the member's words
|
|
16
|
+
// contradict, and the helper pointed at a place that exists.
|
|
17
|
+
const PREMISE = new Set(['wrong_premise', 'conflicts_with_request']);
|
|
18
|
+
const DECISIONS = ['accepted', 'rejected'];
|
|
19
|
+
// After this many refused answers a model that cannot resolve an item goes on;
|
|
20
|
+
// the run reports the item as left without a decision.
|
|
21
|
+
export const LEDGER_REFUSALS = 3;
|
|
22
|
+
const MAX_ITEMS = 60, READ_LIMIT = 5 * 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
// "file:line" in a piece of evidence; null when it names none.
|
|
25
|
+
export function evidenceRef(text) {
|
|
26
|
+
const m = String(text ?? '').match(/(?:^|[\s(`'"])((?:[\w@.-]+\/)*[\w@.-]*[\w-]\.[A-Za-z0-9]+):(\d+)\b/);
|
|
27
|
+
return m ? { file: m[1], line: Number(m[2]) } : null;
|
|
28
|
+
}
|
|
29
|
+
// Whether the place exists in the project: 'ok', 'missing' (no such file, or
|
|
30
|
+
// the file is shorter), or null when there is nothing to check or the file is
|
|
31
|
+
// outside the project or too large to read.
|
|
32
|
+
export function checkRef(cwd, ref) {
|
|
33
|
+
if (!cwd || !ref?.file || path.isAbsolute(ref.file) || ref.file.split(/[\\/]/).includes('..')) return null;
|
|
34
|
+
let real;
|
|
35
|
+
try { real = fs.realpathSync(path.join(cwd, ref.file)); } catch { return 'missing'; }
|
|
36
|
+
const root = fs.realpathSync(cwd);
|
|
37
|
+
if (real !== root && !real.startsWith(root + path.sep)) return null;
|
|
38
|
+
try {
|
|
39
|
+
const stat = fs.statSync(real);
|
|
40
|
+
if (!stat.isFile()) return 'missing';
|
|
41
|
+
if (!ref.line) return 'ok';
|
|
42
|
+
if (stat.size > READ_LIMIT) return null;
|
|
43
|
+
return fs.readFileSync(real, 'utf8').split('\n').length >= ref.line ? 'ok' : 'missing';
|
|
44
|
+
} catch { return null; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const clip = (value, n) => typeof value === 'string' ? value.trim().slice(0, n) : '';
|
|
48
|
+
export const ledgerOf = run => run?.ledger ?? [];
|
|
49
|
+
const open = run => ledgerOf(run).filter(i => i.status === 'open');
|
|
50
|
+
export const blockingItems = run => open(run).filter(i => i.required && i.source === 'brief');
|
|
51
|
+
|
|
52
|
+
// Adds items from one helper answer. `source`: brief (an objection to the
|
|
53
|
+
// brief), review (blocking and major findings) or verify (failed claims).
|
|
54
|
+
export function addItems(managed, source, role, list, { required } = {}) {
|
|
55
|
+
const run = managed.run;
|
|
56
|
+
if (!run || !list?.length) return [];
|
|
57
|
+
const added = [];
|
|
58
|
+
for (const raw of list) {
|
|
59
|
+
if (run.ledger.length >= MAX_ITEMS) break;
|
|
60
|
+
const evidence = clip(raw.evidence, 500), ref = raw.file ? { file: raw.file, line: raw.line ?? null } : evidenceRef(evidence), place = checkRef(managed.cwd, ref);
|
|
61
|
+
const must = source === 'brief' ? PREMISE.has(raw.kind) && !!evidence && place !== 'missing' : source === 'review' ? raw.kind === 'blocking' : !!required;
|
|
62
|
+
const item = { id: `o${run.ledger.length + 1}`, source, role, kind: raw.kind, detail: clip(raw.detail, 500), evidence, ...(ref ? { ref } : {}), ...(place ? { place } : {}), required: must, status: 'open' };
|
|
63
|
+
run.ledger.push(item); added.push(item);
|
|
64
|
+
}
|
|
65
|
+
return added;
|
|
66
|
+
}
|
|
67
|
+
export const itemText = i => `${i.id} [${i.source === 'brief' ? i.kind : i.source === 'review' ? i.kind : 'failed claim'}] ${i.role}: ${i.detail}${i.evidence ? ` (evidence: ${i.evidence}${i.place === 'missing' ? '; the harness found no such place' : ''})` : ''}`;
|
|
68
|
+
export const setStatus = (item, status, extra = {}) => Object.assign(item, { status, ...extra });
|
|
69
|
+
export function saveLedger(managed) {
|
|
70
|
+
const items = ledgerOf(managed.run);
|
|
71
|
+
if (items.length) try { managed.session.sessionManager.appendCustomEntry(LEDGER_ENTRY, { items, at: new Date().toISOString() }); } catch { /* the report still counts them */ }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// resolve_objection: the main agent's decision on one item, kept with what it
|
|
75
|
+
// checked. An accepted item names what changed; a rejected one says why.
|
|
76
|
+
export function resolveTool(owner) {
|
|
77
|
+
return { name: 'resolve_objection', label: 'Objection',
|
|
78
|
+
description: 'Record your decision on an objection a helper raised (its id, such as o1): a brief objection, a review finding or a failed verify claim. Check it first at the place its evidence names. accepted: you agree; say what you changed (a corrected brief, the plan, the code). rejected: say what you checked (file:line, a command and its result) and why the objection does not hold; a rejected required objection goes back to the helper, and a disagreement that remains goes to the member.',
|
|
79
|
+
parameters: { type: 'object', properties: { id: { type: 'string', maxLength: 10 }, decision: { type: 'string', enum: DECISIONS }, checked: { type: 'string', minLength: 1, maxLength: 300 },
|
|
80
|
+
change: { type: 'string', maxLength: 300 }, reason: { type: 'string', maxLength: 500 } }, required: ['id', 'decision', 'checked'], additionalProperties: false },
|
|
81
|
+
execute: async (_id, args) => {
|
|
82
|
+
const item = ledgerOf(owner.run).find(i => i.id === args?.id);
|
|
83
|
+
if (!item) throw Error(`managed-objection-unknown: no objection ${String(args?.id).slice(0, 10)} in this message; the open ones are ${open(owner.run).map(i => i.id).join(', ') || 'none'}.`);
|
|
84
|
+
if (!['open', 'noted'].includes(item.status)) throw Error(`managed-objection-settled: ${item.id} is already ${item.status}.`);
|
|
85
|
+
if (!DECISIONS.includes(args.decision) || !clip(args.checked, 300)) throw Error('managed-objection-invalid: send decision accepted or rejected, and what you checked.');
|
|
86
|
+
if (args.decision === 'accepted' && !clip(args.change, 300)) throw Error('managed-objection-invalid: an accepted objection names what you changed (the brief, the plan or the code).');
|
|
87
|
+
if (args.decision === 'rejected' && !clip(args.reason, 500)) throw Error('managed-objection-invalid: a rejected objection says why it does not hold.');
|
|
88
|
+
const resolution = { decision: args.decision, checked: clip(args.checked, 300), ...(args.change ? { change: clip(args.change, 300) } : {}), ...(args.reason ? { reason: clip(args.reason, 500) } : {}) };
|
|
89
|
+
setStatus(item, args.decision, { resolution });
|
|
90
|
+
const back = args.decision === 'rejected' && item.required ? ` The ${item.role} subagent judges your reason before the turn ends.` : '';
|
|
91
|
+
return { content: [{ type: 'text', text: `Recorded: ${item.id} ${args.decision}.${back}` }], details: { id: item.id, decision: args.decision } };
|
|
92
|
+
} };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Before an edit or a command that may change files: an open premise
|
|
96
|
+
// objection is resolved first. `answer` names the model answer the call came
|
|
97
|
+
// from, so calls sent together count once.
|
|
98
|
+
export function ledgerGuard(run, answer) {
|
|
99
|
+
const items = blockingItems(run);
|
|
100
|
+
if (!items.length) return;
|
|
101
|
+
run.ledgerRefusals ??= new Set();
|
|
102
|
+
if (!run.ledgerRefusals.has(answer) && run.ledgerRefusals.size >= LEDGER_REFUSALS) { run.ledgerSkipped = true; return; }
|
|
103
|
+
run.ledgerRefusals.add(answer);
|
|
104
|
+
throw Error(`managed-objection-open: resolve ${items.map(i => i.id).join(', ')} with resolve_objection before changing files.\n${items.map(itemText).join('\n')}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The receipt's counts (report version 3): objections to briefs, those the
|
|
108
|
+
// main agent decided, the disagreements handed to the member.
|
|
109
|
+
export function briefCounts(run) {
|
|
110
|
+
const briefs = ledgerOf(run).filter(i => i.source === 'brief');
|
|
111
|
+
return { objections: briefs.length, answered: briefs.filter(i => ['accepted', 'rejected', 'withdrawn'].includes(i.status)).length };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// A required rejection the helper has not judged yet; the harness asks it.
|
|
115
|
+
export const toRejudge = run => ledgerOf(run).filter(i => i.source === 'brief' && i.required && i.status === 'rejected' && !i.rejudged);
|
|
116
|
+
export function rejudgeBrief(item) {
|
|
117
|
+
return `You objected to the brief the main agent gave you:\n[${item.kind}] ${item.detail}${item.evidence ? `\nYour evidence: ${item.evidence}` : ''}\n\nThe main agent rejected the objection.\nWhat it checked: ${item.resolution.checked}\nIts reason: ${item.resolution.reason}\n\nJudge its reason against the code and the member's words. When the reason holds, end with an empty brief_issues list. When your objection still holds, repeat it in brief_issues with evidence; the member then decides.`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// One step of the completion gate (true: the gate goes round again). A
|
|
121
|
+
// rejected premise objection goes back to its helper, which withdraws it or
|
|
122
|
+
// keeps it for the member; then the items the main agent never decided are
|
|
123
|
+
// put to it once, with no new review.
|
|
124
|
+
export async function settleLedger(managed, run, { note, send, signal }) {
|
|
125
|
+
const rejected = toRejudge(run)[0];
|
|
126
|
+
if (rejected) {
|
|
127
|
+
rejected.rejudged = true; let reply;
|
|
128
|
+
await note(`Harness: the ${rejected.role} subagent judges the main agent's reason for rejecting ${rejected.id}.`, { phase: 'rejudge', role: rejected.role, id: rejected.id });
|
|
129
|
+
try { reply = (await managed.delegate({ role: rejected.role, task: rejudgeBrief(rejected) }, signal, { harness: true })).content.map(p => p.text).join('\n'); }
|
|
130
|
+
catch { return true; }
|
|
131
|
+
if (!briefIssues(reply)?.length) { setStatus(rejected, 'withdrawn'); return true; }
|
|
132
|
+
setStatus(rejected, 'disputed'); run.disputes.push({ source: rejected.role, brief: true });
|
|
133
|
+
await note(`Harness: the ${rejected.role} subagent keeps its objection; the member decides.\n${itemText(rejected)}`, { phase: 'dispute', role: rejected.role, id: rejected.id });
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
const pending = undecided(run);
|
|
137
|
+
if (!pending.length || run.decideAsked) return false;
|
|
138
|
+
run.decideAsked = true;
|
|
139
|
+
await send(decideText(pending), { phase: 'answer', ids: pending.map(i => i.id), issues: pending.slice(0, 10).map(i => ({ kind: i.kind, detail: i.detail })) });
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// What the main agent still has to decide before the turn ends: required
|
|
144
|
+
// items, review findings above minor, brief objections it never answered.
|
|
145
|
+
export const undecided = run => open(run).filter(i => i.required || i.source !== 'verify');
|
|
146
|
+
export function decideText(items) {
|
|
147
|
+
return `Harness: ${items.length} objection(s) have no decision yet:\n${items.map(i => '- ' + itemText(i)).join('\n')}\nCheck each at the place its evidence names, then record your decision with resolve_objection: accepted with what you changed, or rejected with what you checked and why. Minor review findings need nothing; no new review follows.`;
|
|
148
|
+
}
|
|
149
|
+
// The final status line's words about the ledger.
|
|
150
|
+
export function ledgerWords(run) {
|
|
151
|
+
const items = ledgerOf(run), count = s => items.filter(i => i.status === s).length, words = [];
|
|
152
|
+
if (!items.length && !run.minorFindings) return null;
|
|
153
|
+
for (const [status, label] of [['accepted', 'accepted'], ['rejected', 'rejected'], ['withdrawn', 'withdrawn by the helper'], ['cleared', 'cleared by a later check'], ['disputed', 'for the member'], ['open', 'left without a decision'], ['noted', 'noted']])
|
|
154
|
+
if (count(status)) words.push(`${count(status)} ${label}`);
|
|
155
|
+
if (run.minorFindings) words.push(`${run.minorFindings} minor finding(s) listed`);
|
|
156
|
+
return `objections: ${words.join(', ')}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A review of this turn's patch replaces the findings of the one before it:
|
|
160
|
+
// its blocking and major findings are the items, its minor ones only counted.
|
|
161
|
+
// A finding it repeats keeps its item (and the main agent's decision); one it
|
|
162
|
+
// no longer reports is cleared, or withdrawn when the agent had rejected it.
|
|
163
|
+
const sameFinding = (item, f) => (item.ref?.file ?? null) === (f.file || null) && (item.ref?.line === f.line || item.detail === clip(f.issue, 500));
|
|
164
|
+
export function recordFindings(managed, findings) {
|
|
165
|
+
const run = managed.run;
|
|
166
|
+
if (!run || !findings) return;
|
|
167
|
+
// A re-review after a fix read only the files changed since: an item about another file stands.
|
|
168
|
+
const focus = managed.reviewFocus?.files ? new Set(managed.reviewFocus.files) : null;
|
|
169
|
+
const kept = findings.filter(f => f.severity !== 'minor'), live = ledgerOf(run).filter(i => i.source === 'review' && ['open', 'rejected'].includes(i.status)
|
|
170
|
+
&& (!focus || i.kind === 'blocking' || !i.ref?.file || focus.has(i.ref.file)));
|
|
171
|
+
for (const item of live) if (!kept.some(f => f.severity === item.kind && sameFinding(item, f))) setStatus(item, item.status === 'open' ? 'cleared' : 'withdrawn');
|
|
172
|
+
addItems(managed, 'review', 'review', kept.filter(f => !live.some(i => i.kind === f.severity && sameFinding(i, f))).map(f => ({ kind: f.severity, detail: f.issue, evidence: f.evidence, file: f.file || null, line: f.line })));
|
|
173
|
+
run.minorFindings = findings.length - kept.length;
|
|
174
|
+
}
|
|
175
|
+
// The files changed since the last review of this turn's patch, for a
|
|
176
|
+
// re-review after a fix; null when that cannot be told.
|
|
177
|
+
export async function reviewFocus(managed) {
|
|
178
|
+
const before = managed.reviewFiles, now = before ? await managed.changedFiles?.() : null;
|
|
179
|
+
if (!before || !now) return null;
|
|
180
|
+
const files = [...new Set([...before.keys(), ...now.keys()])].filter(name => before.get(name) !== now.get(name)).sort();
|
|
181
|
+
return files.length ? { files: files.slice(0, 200) } : null;
|
|
182
|
+
}
|
|
183
|
+
// The main agent's decisions on items of one source, for the helper that judges them again.
|
|
184
|
+
export function decisionsText(run, source) {
|
|
185
|
+
const decided = ledgerOf(run).filter(i => i.source === source && i.resolution);
|
|
186
|
+
return decided.length ? `\n\nThe main agent's recorded decisions:\n${decided.map(i => `- ${i.detail}: ${i.resolution.decision}; checked: ${i.resolution.checked}${i.resolution.reason ? `; reason: ${i.resolution.reason}` : ''}${i.resolution.change ? `; changed: ${i.resolution.change}` : ''}`).join('\n')}` : '';
|
|
187
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { briefIssues, currentPlan, planText, verifyVerdicts } from './workflow.mjs';
|
|
2
|
+
import { addItems, itemText, ledgerOf, setStatus } from './ledger.mjs';
|
|
3
|
+
export { resolveTool } from './ledger.mjs';
|
|
2
4
|
|
|
3
5
|
// A helper may object to the brief it was given, with evidence. It never
|
|
4
|
-
// decides and never does other work instead: the main agent decides and
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// stops the exchange, and the member decides.
|
|
6
|
+
// decides and never does other work instead: the main agent decides and
|
|
7
|
+
// records its decision in the ledger (ledger.mjs): accepted with what it
|
|
8
|
+
// changed, or rejected with what it checked and why. The same helper
|
|
9
|
+
// objecting a second time in a run stops the exchange, and the member decides.
|
|
8
10
|
|
|
9
11
|
// The member's own words for a helper: the current message and, while a
|
|
10
12
|
// message only continues earlier work ("tiếp tục", a short follow-up), the
|
|
@@ -33,41 +35,61 @@ export function anchorText(manager, harness) {
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
// Before a helper starts: once a helper objected twice in a run the member
|
|
36
|
-
// decides, so the main agent cannot send it another brief before then.
|
|
37
|
-
//
|
|
38
|
-
|
|
38
|
+
// decides, so the main agent cannot send it another brief before then. An
|
|
39
|
+
// open premise objection of the role is resolved with resolve_objection
|
|
40
|
+
// first; a new brief answers any other objection of the role. A review the
|
|
41
|
+
// main agent asks for itself: none under a required review (the harness
|
|
42
|
+
// reviews the final patch), else once, and again only after blocking findings.
|
|
43
|
+
export function beforeDelegate(run, role, harness, { since = null, review = null, changed = false, asked = false } = {}) {
|
|
39
44
|
if (!run || harness) return;
|
|
40
45
|
if (run.disputes.some(d => d.source === role && d.brief)) throw Error(`managed-helper-disputed: the ${role} subagent objected to your briefs twice for this message, so the member decides. Tell them what it objects to and what you decided; do not delegate to it again before their next message.`);
|
|
41
|
-
|
|
46
|
+
const mine = ledgerOf(run).filter(i => i.source === 'brief' && i.role === role && i.status === 'open');
|
|
47
|
+
const blocked = mine.filter(i => i.required);
|
|
48
|
+
if (blocked.length) throw Error(`managed-objection-open: the ${role} subagent objected to your brief; record your decision with resolve_objection before you brief it again.\n${blocked.map(itemText).join('\n')}`);
|
|
49
|
+
// Reviewers the member asked for (copies.mjs) run at their word.
|
|
50
|
+
if (role === 'review' && !since && changed && !asked) {
|
|
51
|
+
if (run.policy.review === 'require') throw Error('managed-review-by-harness: the harness reviews the final patch when you finish; do not ask for a review yourself. Finish the change and its checks.');
|
|
52
|
+
if (run.mainReviews > 0 && !(review?.stale && review.blocking > 0)) throw Error('managed-review-once: this change was reviewed already. Fix its blocking findings and ask again only then; fix major findings or reject them with resolve_objection; minor findings need nothing.');
|
|
53
|
+
}
|
|
54
|
+
if (role === 'review' && !since) run.mainReviews = (run.mainReviews ?? 0) + 1;
|
|
55
|
+
for (const item of mine) setStatus(item, 'accepted', { resolution: { decision: 'accepted', checked: 'sent the role a new brief', change: 'a new brief' } });
|
|
42
56
|
}
|
|
43
57
|
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
// it is not relayed.
|
|
58
|
+
// After a helper answered: an objection to the main agent's brief goes into
|
|
59
|
+
// the ledger, is shown to the member and handed to the main agent with the
|
|
60
|
+
// answer. Returns the note the main agent reads under the helper's answer
|
|
61
|
+
// ('' when there is none). The harness's own briefs (its review, its
|
|
62
|
+
// re-check) are fixed text: an objection to one only restates the member's
|
|
63
|
+
// request, so it is not relayed.
|
|
51
64
|
export async function relayObjection(managed, role, reply, harness) {
|
|
52
65
|
const run = managed.run, issues = harness ? null : briefIssues(reply);
|
|
53
66
|
if (!run || !issues?.length) return '';
|
|
54
|
-
const second = run.
|
|
55
|
-
run.
|
|
56
|
-
if (second) run.disputes.push({ source: role, brief: true });
|
|
67
|
+
const second = ledgerOf(run).some(i => i.source === 'brief' && i.role === role), items = addItems(managed, 'brief', role, issues), list = items.map(itemText).join('\n');
|
|
68
|
+
if (second) { run.disputes.push({ source: role, brief: true }); for (const item of items) setStatus(item, 'disputed'); }
|
|
57
69
|
await managed.session.sendCustomMessage({ customType: 'agent-watch-process', display: true,
|
|
58
70
|
content: second ? `Harness: the ${role} subagent objected to a brief a second time for this message; the exchange stops and the member decides.\n${list}`
|
|
59
71
|
: `Harness: the ${role} subagent objected to the brief the main agent gave it:\n${list}`,
|
|
60
|
-
details: { phase: second ? 'dispute' : 'objection', role, by: 'main', issues: issues.map(({ kind, detail }) => ({ kind, detail })) } }, { triggerTurn: false });
|
|
61
|
-
|
|
62
|
-
|
|
72
|
+
details: { phase: second ? 'dispute' : 'objection', role, by: 'main', issues: issues.map(({ kind, detail }) => ({ kind, detail })), ids: items.map(i => i.id) } }, { triggerTurn: false });
|
|
73
|
+
if (second) return `\n\n[Harness] The ${role} subagent objected to your brief again. This is its second objection for this message, so the harness ends the exchange: do not delegate to it again before the member's next message. Tell the member plainly what it objects to and what you decided, and let them decide.`;
|
|
74
|
+
const blocking = items.filter(i => i.required);
|
|
75
|
+
return `\n\n[Harness] The ${role} subagent objected to your brief:\n${list}\nCheck each at the place its evidence names, then record your decision with resolve_objection (accepted with what you changed, or rejected with what you checked and why).${blocking.length ? ` Until you do, file changes are refused: ${blocking.map(i => i.id).join(', ')} say${blocking.length === 1 ? 's' : ''} the brief rests on a wrong premise or conflicts with the request.` : ''} A rejected objection goes back to the helper; a second objection from it for this message goes to the member.`;
|
|
63
76
|
}
|
|
64
77
|
|
|
65
|
-
// Verify's verdicts, counted for the receipt
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
78
|
+
// Verify's verdicts, counted for the receipt; its failed claims go into the
|
|
79
|
+
// ledger (required under a required verify, noted otherwise). Under a
|
|
80
|
+
// required verify the failed claims are the run's to settle; once the agent
|
|
81
|
+
// has been sent back with them, only the harness's own re-check replaces
|
|
82
|
+
// them, and a claim it no longer fails is cleared.
|
|
83
|
+
export function recordVerdicts(managed, reply, harness, details) {
|
|
84
|
+
const run = managed.run, found = verifyVerdicts(reply);
|
|
70
85
|
details.verdicts = found ? Object.fromEntries(['pass', 'fail', 'unverifiable'].map(s => [s, found.filter(v => v.status === s).length])) : null;
|
|
71
|
-
if (run && found && (harness || !run.claimsAsked))
|
|
86
|
+
if (run && found && (harness || !run.claimsAsked)) {
|
|
87
|
+
run.verifyFails = found.filter(v => v.status === 'fail');
|
|
88
|
+
const failed = new Set(run.verifyFails.map(v => v.claim)), required = run.policy.verify === 'require';
|
|
89
|
+
for (const item of ledgerOf(run)) if (item.source === 'verify' && ['open', 'rejected', 'noted'].includes(item.status) && !failed.has(item.detail)) setStatus(item, 'cleared');
|
|
90
|
+
const known = new Set(ledgerOf(run).filter(i => i.source === 'verify' && i.status !== 'cleared').map(i => i.detail));
|
|
91
|
+
const added = addItems(managed, 'verify', 'verify', run.verifyFails.filter(v => !known.has(v.claim)).map(v => ({ kind: 'fail', detail: v.claim, evidence: v.evidence })), { required });
|
|
92
|
+
if (!required) for (const item of added) setStatus(item, 'noted');
|
|
93
|
+
}
|
|
72
94
|
return found ? ' · ' + Object.entries(details.verdicts).filter(([, n]) => n).map(([s, n]) => `${n} ${s}`).join(', ') : '';
|
|
73
95
|
}
|
|
@@ -176,6 +176,15 @@ export function reviewParts(snapshot, size = REVIEW_PART_BYTES) {
|
|
|
176
176
|
return { parts: parts.length ? parts : [{ files: [], text: `Patch snapshot ${snapshot.digest}: every changed file is too large to review.${skipped}` }], omitted };
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
// A re-review after a fix (focus: the files changed since the last review)
|
|
180
|
+
// reads only the parts that hold one of them: the other parts were reviewed
|
|
181
|
+
// and have not changed. Without such a part, or in one part, all of it.
|
|
182
|
+
export function focusParts(review, focus) {
|
|
183
|
+
if (!focus?.files?.length || review.parts.length < 2) return review;
|
|
184
|
+
const files = new Set(focus.files), kept = review.parts.filter(part => part.files?.some(file => files.has(file)));
|
|
185
|
+
return kept.length ? { ...review, parts: kept } : review;
|
|
186
|
+
}
|
|
187
|
+
|
|
179
188
|
// One answer from the review of every part: each part's summary, then the
|
|
180
189
|
// findings of all parts (most severe first) and their objections to the brief
|
|
181
190
|
// in one JSON block. `parsed`: every part gave its JSON.
|