@tea-agent/loop-agent 0.22.0 → 0.24.0
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/AGENTS.md +42 -108
- package/CHANGELOG.md +85 -0
- package/README.md +8 -5
- package/bin/agent-worker.js +0 -0
- package/dist/application/context-usage/skill-resolution-stats.js +263 -0
- package/dist/application/dag/generate-task-dag.js +17 -3
- package/dist/cli/command-definitions.js +8 -7
- package/dist/cli/program.js +17 -15
- package/dist/commands/doctor.js +269 -18
- package/dist/commands/init.js +101 -86
- package/dist/commands/stats.js +40 -11
- package/dist/executors/shell-executor.js +20 -7
- package/dist/shared/operator/capabilities.js +486 -3
- package/dist/worker/console/app-data.js +6 -0
- package/dist/worker/console/chat/artifact-card.js +23 -0
- package/dist/worker/console/chat/chat-event-store.js +495 -0
- package/dist/worker/console/chat/chat-ui-policy.js +25 -0
- package/dist/worker/console/chat/composer-draft-store.js +45 -0
- package/dist/worker/console/chat/context-panel.js +54 -0
- package/dist/worker/console/chat/contract-apply-receipt-store.js +174 -0
- package/dist/worker/console/chat/explore-tools.js +299 -0
- package/dist/worker/console/chat/human-gate-card.js +37 -0
- package/dist/worker/console/chat/instruction-skills.js +217 -0
- package/dist/worker/console/chat/interview-adapter.js +136 -0
- package/dist/worker/console/chat/model-resolver.js +106 -0
- package/dist/worker/console/chat/operation-card.js +23 -0
- package/dist/worker/console/chat/pi-console-config.js +158 -0
- package/dist/worker/console/chat/pi-runtime.js +1143 -0
- package/dist/worker/console/chat/repo-browser.js +140 -0
- package/dist/worker/console/chat/repo-walk.js +116 -0
- package/dist/worker/console/chat/resource-loader.js +67 -0
- package/dist/worker/console/chat/routes.js +1646 -0
- package/dist/worker/console/chat/runtime-context.js +24 -0
- package/dist/worker/console/chat/runtime-selection.js +37 -0
- package/dist/worker/console/chat/session-store.js +437 -0
- package/dist/worker/console/chat/shortcuts.js +15 -0
- package/dist/worker/console/chat/tool-adapter.js +125 -0
- package/dist/worker/console/chat/tools.js +195 -0
- package/dist/worker/console/chat/usage.js +37 -0
- package/dist/worker/console/chat/workspace-landing.js +56 -0
- package/dist/worker/console/dag-confirmation.js +42 -8
- package/dist/worker/console/human-gate-token.js +130 -0
- package/dist/worker/console/mutation-gate-receipt-store.js +184 -0
- package/dist/worker/console/operation-runner.js +6 -2
- package/dist/worker/console/operation-sse.js +26 -0
- package/dist/worker/console/operator-actions.js +420 -7
- package/dist/worker/console/server.js +68 -1
- package/dist/worker/console/static/assets/index-BTbrEHnO.css +1 -0
- package/dist/worker/console/static/assets/index-D9qLevoP.js +27 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/feature/profile-schema.js +1 -1
- package/dist/workflows/dag/backend-test-markdown-workflow.js +9 -5
- package/dist/workflows/dag/backend-test-result-contract.js +229 -0
- package/dist/workflows/dag/frontend-lint-baseline.js +4 -4
- package/dist/workflows/dag/init-hybrid.js +71 -22
- package/dist/workflows/dag/node-execution.js +38 -1
- package/dist/workflows/dag/output-protocol.js +89 -0
- package/dist/workflows/dag/prompt.js +35 -1
- package/dist/workflows/dag/recovery-recommendation.js +45 -0
- package/dist/workflows/dag/report.js +28 -1
- package/dist/workflows/dag/rerun-task.js +1 -1
- package/dist/workflows/dag/scheduler.js +9 -0
- package/dist/workflows/dag/types.js +12 -0
- package/dist/workflows/dag/validate.js +55 -0
- package/docs/README.md +73 -156
- package/docs/architecture/README.md +7 -6
- package/docs/architecture/dag-execution.md +2 -2
- package/docs/architecture/evolution.md +16 -14
- package/docs/architecture/system-overview.md +1 -1
- package/docs/architecture/worker-and-feature.md +3 -3
- package/docs/governance/README.md +15 -0
- package/docs/{harness-methodology-debugging.md → governance/harness-methodology-debugging.md} +27 -3
- package/docs/init-surface.manifest.json +22 -4
- package/docs/operations/README.md +12 -0
- package/docs/{local-development-environment.md → operations/local-development-environment.md} +1 -1
- package/docs/skills/vetted-skill-registry.md +23 -3
- package/docs/templates/README.md +55 -0
- package/docs/templates/backend-test-dag.json +2 -2
- package/docs/templates/evaluation/agents-map-slim-v1.candidate.json +9 -0
- package/docs/templates/evaluation/agents-map-slim-v1.md +87 -0
- package/docs/templates/evaluation/agents-map-verbose-v0.candidate.json +9 -0
- package/docs/templates/evaluation/agents-map-verbose-v0.md +153 -0
- package/docs/templates/hybrid-dag.json +1 -1
- package/docs/templates/progress-log.md +9 -2
- package/harness.json +4 -4
- package/package.json +5 -5
- package/scripts/kb-bootstrap-init-skeleton.sh +2 -2
- package/skills/agent-worker/SKILL.md +1 -1
- package/skills/grill-with-docs/SKILL.md +44 -52
- package/skills/grill-with-docs/adr-format.md +37 -26
- package/skills/grill-with-docs/context-format.md +18 -26
- package/skills/loop-agent/SKILL.md +28 -112
- package/skills/loop-agent/references/command-reference.md +9 -3
- package/skills/loop-agent/references/harness-policy.md +3 -3
- package/skills/loop-agent/references/hybrid-dag.md +1 -1
- package/skills/loop-agent/references/orchestrator-and-interventions.md +1 -1
- package/skills/loop-agent/references/task-workflow.md +2 -0
- package/skills/systematic-debugging/SKILL.md +20 -4
- package/skills/test-driven-development/SKILL.md +10 -3
- package/dist/worker/console/static/assets/index-CUDke82y.js +0 -18
- package/dist/worker/console/static/assets/index-wSEksVSO.css +0 -1
- /package/docs/{harness-methodology-tdd.md → governance/harness-methodology-tdd.md} +0 -0
- /package/docs/{harness-methodology-verification.md → governance/harness-methodology-verification.md} +0 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { scrubSecrets } from "./explore-tools.js";
|
|
3
|
+
function summary(text, max = 320) {
|
|
4
|
+
if (!text)
|
|
5
|
+
return undefined;
|
|
6
|
+
return scrubSecrets(text).scrubbed
|
|
7
|
+
.replace(/\bghp_[A-Za-z0-9]{20,}\b/g, "[REDACTED]")
|
|
8
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, "Bearer [REDACTED]")
|
|
9
|
+
.slice(0, max);
|
|
10
|
+
}
|
|
11
|
+
export function projectRuntimeContext(input) {
|
|
12
|
+
return {
|
|
13
|
+
readOnly: true,
|
|
14
|
+
systemPrompt: {
|
|
15
|
+
hash: createHash("sha256").update(input.systemPrompt).digest("hex"),
|
|
16
|
+
summary: summary(input.systemPrompt),
|
|
17
|
+
},
|
|
18
|
+
skills: input.skills.map((skill) => ({ ...skill, description: summary(skill.description, 200) ?? "" })),
|
|
19
|
+
resources: { mode: "closed", noContextFiles: true, noSkills: true, noExtensions: true, hasBash: false, activeToolCount: input.activeTools.length },
|
|
20
|
+
model: input.model,
|
|
21
|
+
thinkingLevel: input.thinkingLevel,
|
|
22
|
+
suffix: input.systemPromptSuffix ? { present: true, summary: summary(input.systemPromptSuffix) } : { present: false },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure runtime-selection helpers shared by the Operator Chat UI and tests.
|
|
3
|
+
*
|
|
4
|
+
* These functions encode the merge/match semantics between persisted session
|
|
5
|
+
* runtime state (model + thinking level) and the read-only runtime context
|
|
6
|
+
* snapshot. They are intentionally side-effect-free so they can be unit-tested
|
|
7
|
+
* in a plain Node environment (the React component owns the network race
|
|
8
|
+
* guards; these helpers only define what "matches" and "merged" mean).
|
|
9
|
+
*/
|
|
10
|
+
/** Format a model reference for `<option value>` keys and equality checks. */
|
|
11
|
+
export function formatModelRef(provider, modelId) {
|
|
12
|
+
return `${provider}/${modelId}`;
|
|
13
|
+
}
|
|
14
|
+
/** Normalize a server record into the optional model/thinking patch fields. */
|
|
15
|
+
export function runtimeSelectionFromRecord(record) {
|
|
16
|
+
return {
|
|
17
|
+
...(record.modelProvider && record.modelId
|
|
18
|
+
? { model: { provider: record.modelProvider, modelId: record.modelId } }
|
|
19
|
+
: {}),
|
|
20
|
+
...(record.thinkingLevel ? { thinkingLevel: record.thinkingLevel } : {}),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Merge a patch onto a session, preferring patch values and keeping the id. */
|
|
24
|
+
export function mergeSessionRuntime(current, patch) {
|
|
25
|
+
return {
|
|
26
|
+
...current,
|
|
27
|
+
model: patch.model ?? current.model,
|
|
28
|
+
thinkingLevel: patch.thinkingLevel ?? current.thinkingLevel,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** True when `current` already carries the values in `patch`. */
|
|
32
|
+
export function sessionRuntimeMatches(current, patch) {
|
|
33
|
+
const next = mergeSessionRuntime(current, patch);
|
|
34
|
+
return (next.model?.provider === current.model?.provider &&
|
|
35
|
+
next.model?.modelId === current.model?.modelId &&
|
|
36
|
+
next.thinkingLevel === current.thinkingLevel);
|
|
37
|
+
}
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator Chat — session store + tool dispatcher (plan W7 / W5).
|
|
3
|
+
*
|
|
4
|
+
* Owns:
|
|
5
|
+
* - Chat session metadata persistence (app-data/chats)
|
|
6
|
+
* - Tool-call dispatch from the Pi runtime into the operator action layer
|
|
7
|
+
* (Gate 3 per-call authorization)
|
|
8
|
+
* - History record (for degraded-mode replay, V13)
|
|
9
|
+
*
|
|
10
|
+
* The dispatcher is the SINGLE place where a Chat tool invocation becomes an
|
|
11
|
+
* operator action call. Every invocation is re-authorized here.
|
|
12
|
+
*/
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { readdir, rm } from "node:fs/promises";
|
|
15
|
+
import { readJsonIfExists, writeSecureJson, } from "../app-data.js";
|
|
16
|
+
import { dispatchOperatorAction } from "../operator-actions.js";
|
|
17
|
+
import { authorizeOperatorChatTool } from "./tools.js";
|
|
18
|
+
import { projectOperationForChat } from "./chat-event-store.js";
|
|
19
|
+
import { scrubSecrets } from "./explore-tools.js";
|
|
20
|
+
function recordPath(appData, sessionId) {
|
|
21
|
+
return path.join(appData.chats, `${sessionId}.json`);
|
|
22
|
+
}
|
|
23
|
+
export class ChatSessionStore {
|
|
24
|
+
appData;
|
|
25
|
+
/**
|
|
26
|
+
* Per-session write lock. Each mutating op chains onto the previous one so
|
|
27
|
+
* concurrent appendMessage / recordToolInvocation calls do not lose updates
|
|
28
|
+
* via last-write-wins on the same JSON record.
|
|
29
|
+
*/
|
|
30
|
+
writeChains = new Map();
|
|
31
|
+
constructor(appData) {
|
|
32
|
+
this.appData = appData;
|
|
33
|
+
}
|
|
34
|
+
/** Read + migrate legacy V1 records into the V2 in-memory shape. */
|
|
35
|
+
async get(sessionId) {
|
|
36
|
+
const raw = await readJsonIfExists(recordPath(this.appData, sessionId));
|
|
37
|
+
if (!raw || raw.sessionId !== sessionId)
|
|
38
|
+
return undefined;
|
|
39
|
+
return {
|
|
40
|
+
schemaVersion: 2,
|
|
41
|
+
sessionId,
|
|
42
|
+
repoFingerprint: raw.repoFingerprint ?? this.appData.fingerprint,
|
|
43
|
+
repoRootDisplay: raw.repoRootDisplay ?? this.appData.repoRoot,
|
|
44
|
+
state: raw.state === "archived" ? "archived" : "active",
|
|
45
|
+
...(typeof raw.title === "string" ? { title: raw.title } : {}),
|
|
46
|
+
...(typeof raw.sessionFile === "string" ? { sessionFile: raw.sessionFile } : {}),
|
|
47
|
+
createdAt: raw.createdAt ?? new Date(0).toISOString(),
|
|
48
|
+
updatedAt: raw.updatedAt ?? raw.createdAt ?? new Date(0).toISOString(),
|
|
49
|
+
...(typeof raw.modelProvider === "string" ? { modelProvider: raw.modelProvider } : {}),
|
|
50
|
+
...(typeof raw.modelId === "string" ? { modelId: raw.modelId } : {}),
|
|
51
|
+
...(typeof raw.thinkingLevel === "string" ? { thinkingLevel: raw.thinkingLevel } : {}),
|
|
52
|
+
messages: Array.isArray(raw.messages) ? raw.messages : [],
|
|
53
|
+
toolInvocations: Array.isArray(raw.toolInvocations) ? raw.toolInvocations : [],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async listMessages(sessionId, options = {}) {
|
|
57
|
+
const record = await this.get(sessionId);
|
|
58
|
+
if (!record)
|
|
59
|
+
throw new Error(`chat session not found: ${sessionId}`);
|
|
60
|
+
const limit = Math.max(1, Math.min(50, Math.floor(options.limit ?? 30)));
|
|
61
|
+
const beforeIndex = options.beforeId
|
|
62
|
+
? record.messages.findIndex((message) => message.id === options.beforeId)
|
|
63
|
+
: record.messages.length;
|
|
64
|
+
const end = beforeIndex < 0 ? record.messages.length : beforeIndex;
|
|
65
|
+
const start = Math.max(0, end - limit);
|
|
66
|
+
const messages = record.messages.slice(start, end);
|
|
67
|
+
return {
|
|
68
|
+
messages,
|
|
69
|
+
hasMore: start > 0,
|
|
70
|
+
...(messages[0] ? { nextCursor: messages[0].id } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** List repo-bound sessions, newest first. Deleted records are absent. */
|
|
74
|
+
async list(options) {
|
|
75
|
+
let names;
|
|
76
|
+
try {
|
|
77
|
+
names = await readdir(this.appData.chats);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
const records = await Promise.all(names
|
|
83
|
+
.filter((name) => name.endsWith(".json"))
|
|
84
|
+
.map((name) => this.get(name.slice(0, -".json".length))));
|
|
85
|
+
return records
|
|
86
|
+
.filter((record) => Boolean(record) && (options?.includeArchived || record.state === "active"))
|
|
87
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
88
|
+
}
|
|
89
|
+
/** Serialize a mutating op per session. */
|
|
90
|
+
serialize(sessionId, op) {
|
|
91
|
+
const prev = this.writeChains.get(sessionId) ?? Promise.resolve();
|
|
92
|
+
const next = prev.then(op, op);
|
|
93
|
+
// Keep the chain reference for the next serializer; swallow this op's
|
|
94
|
+
// rejection so it never breaks the chain (callers still see the result).
|
|
95
|
+
this.writeChains.set(sessionId, next.then(() => undefined, () => undefined));
|
|
96
|
+
return next;
|
|
97
|
+
}
|
|
98
|
+
async create(input) {
|
|
99
|
+
return this.serialize(input.sessionId, async () => {
|
|
100
|
+
const now = new Date().toISOString();
|
|
101
|
+
const record = {
|
|
102
|
+
schemaVersion: 2,
|
|
103
|
+
sessionId: input.sessionId,
|
|
104
|
+
repoFingerprint: this.appData.fingerprint,
|
|
105
|
+
repoRootDisplay: this.appData.repoRoot,
|
|
106
|
+
state: "active",
|
|
107
|
+
...(input.sessionFile ? { sessionFile: input.sessionFile } : {}),
|
|
108
|
+
createdAt: now,
|
|
109
|
+
updatedAt: now,
|
|
110
|
+
modelProvider: input.modelProvider,
|
|
111
|
+
modelId: input.modelId,
|
|
112
|
+
messages: [],
|
|
113
|
+
toolInvocations: [],
|
|
114
|
+
};
|
|
115
|
+
await writeSecureJson(recordPath(this.appData, input.sessionId), record);
|
|
116
|
+
return record;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async forkFrom(input) {
|
|
120
|
+
const source = await this.get(input.sourceSessionId);
|
|
121
|
+
if (!source)
|
|
122
|
+
throw new Error(`chat session not found: ${input.sourceSessionId}`);
|
|
123
|
+
return this.serialize(input.targetSessionId, async () => {
|
|
124
|
+
const now = new Date().toISOString();
|
|
125
|
+
const record = {
|
|
126
|
+
...source,
|
|
127
|
+
sessionId: input.targetSessionId,
|
|
128
|
+
state: "active",
|
|
129
|
+
...(input.targetSessionFile ? { sessionFile: input.targetSessionFile } : { sessionFile: undefined }),
|
|
130
|
+
createdAt: now,
|
|
131
|
+
updatedAt: now,
|
|
132
|
+
messages: structuredClone(source.messages),
|
|
133
|
+
toolInvocations: structuredClone(source.toolInvocations),
|
|
134
|
+
};
|
|
135
|
+
await writeSecureJson(recordPath(this.appData, input.targetSessionId), record);
|
|
136
|
+
return record;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async rename(sessionId, title) {
|
|
140
|
+
return this.serialize(sessionId, async () => {
|
|
141
|
+
const record = await this.get(sessionId);
|
|
142
|
+
if (!record)
|
|
143
|
+
throw new Error(`chat session not found: ${sessionId}`);
|
|
144
|
+
record.title = title.trim().slice(0, 160) || undefined;
|
|
145
|
+
record.updatedAt = new Date().toISOString();
|
|
146
|
+
await writeSecureJson(recordPath(this.appData, sessionId), record);
|
|
147
|
+
return record;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
async setRuntimeSelection(sessionId, selection) {
|
|
151
|
+
return this.serialize(sessionId, async () => {
|
|
152
|
+
const record = await this.get(sessionId);
|
|
153
|
+
if (!record)
|
|
154
|
+
throw new Error(`chat session not found: ${sessionId}`);
|
|
155
|
+
if (selection.modelProvider !== undefined)
|
|
156
|
+
record.modelProvider = selection.modelProvider;
|
|
157
|
+
if (selection.modelId !== undefined)
|
|
158
|
+
record.modelId = selection.modelId;
|
|
159
|
+
if (selection.thinkingLevel !== undefined)
|
|
160
|
+
record.thinkingLevel = selection.thinkingLevel;
|
|
161
|
+
record.updatedAt = new Date().toISOString();
|
|
162
|
+
await writeSecureJson(recordPath(this.appData, sessionId), record);
|
|
163
|
+
return record;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
async setState(sessionId, state) {
|
|
167
|
+
return this.serialize(sessionId, async () => {
|
|
168
|
+
const record = await this.get(sessionId);
|
|
169
|
+
if (!record)
|
|
170
|
+
throw new Error(`chat session not found: ${sessionId}`);
|
|
171
|
+
record.state = state;
|
|
172
|
+
record.updatedAt = new Date().toISOString();
|
|
173
|
+
await writeSecureJson(recordPath(this.appData, sessionId), record);
|
|
174
|
+
return record;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
async remove(sessionId) {
|
|
178
|
+
return this.serialize(sessionId, async () => {
|
|
179
|
+
const record = await this.get(sessionId);
|
|
180
|
+
if (!record)
|
|
181
|
+
return false;
|
|
182
|
+
await rm(recordPath(this.appData, sessionId), { force: true });
|
|
183
|
+
return true;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
async appendMessage(sessionId, message) {
|
|
187
|
+
return this.serialize(sessionId, async () => {
|
|
188
|
+
const record = await this.get(sessionId);
|
|
189
|
+
if (!record)
|
|
190
|
+
throw new Error(`chat session not found: ${sessionId}`);
|
|
191
|
+
const stored = {
|
|
192
|
+
...message,
|
|
193
|
+
id: `m_${record.messages.length + 1}_${Math.random().toString(36).slice(2, 8)}`,
|
|
194
|
+
at: new Date().toISOString(),
|
|
195
|
+
};
|
|
196
|
+
record.messages.push(stored);
|
|
197
|
+
record.updatedAt = stored.at;
|
|
198
|
+
await writeSecureJson(recordPath(this.appData, sessionId), record);
|
|
199
|
+
return stored;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
async recordToolInvocation(sessionId, entry) {
|
|
203
|
+
await this.serialize(sessionId, async () => {
|
|
204
|
+
const record = await this.get(sessionId);
|
|
205
|
+
if (!record)
|
|
206
|
+
return;
|
|
207
|
+
record.toolInvocations.push({ ...entry, at: new Date().toISOString() });
|
|
208
|
+
record.updatedAt = new Date().toISOString();
|
|
209
|
+
await writeSecureJson(recordPath(this.appData, sessionId), record);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Dispatch a Chat tool invocation into the operator action layer.
|
|
215
|
+
*
|
|
216
|
+
* Gate 3 (per-call): authorizeOperatorChatTool is re-checked here. Even if a
|
|
217
|
+
* malicious or drifted session somehow requested a denied tool, this gate
|
|
218
|
+
* refuses before any operator action runs (V12 / V15).
|
|
219
|
+
*
|
|
220
|
+
* High-risk actions (contractApply / runDag / etc.) are NOT registered as
|
|
221
|
+
* tools, so they never reach here; but we still deny defensively.
|
|
222
|
+
*/
|
|
223
|
+
function redactHumanGateToken(value) {
|
|
224
|
+
const clone = structuredClone(value);
|
|
225
|
+
const confirmation = clone.confirmation;
|
|
226
|
+
if (confirmation && typeof confirmation === "object") {
|
|
227
|
+
delete confirmation.humanGateToken;
|
|
228
|
+
delete confirmation.dagBytesPath;
|
|
229
|
+
}
|
|
230
|
+
delete clone.humanGateToken;
|
|
231
|
+
return clone;
|
|
232
|
+
}
|
|
233
|
+
export async function dispatchChatToolCall(ctx, toolName, args, clientRequestId) {
|
|
234
|
+
const decision = authorizeOperatorChatTool(toolName);
|
|
235
|
+
if (!decision.ok) {
|
|
236
|
+
return {
|
|
237
|
+
ok: false,
|
|
238
|
+
toolName,
|
|
239
|
+
errorCode: decision.code,
|
|
240
|
+
message: decision.message,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const result = await dispatchOperatorAction(ctx, {
|
|
244
|
+
action: decision.toolId,
|
|
245
|
+
actionParams: args,
|
|
246
|
+
clientRequestId,
|
|
247
|
+
});
|
|
248
|
+
if (result.kind === "accepted") {
|
|
249
|
+
const canonical = await ctx.operations.get(result.body.operationId);
|
|
250
|
+
return {
|
|
251
|
+
ok: true,
|
|
252
|
+
toolName: decision.toolId,
|
|
253
|
+
result: {
|
|
254
|
+
schemaVersion: 1,
|
|
255
|
+
accepted: true,
|
|
256
|
+
operationId: result.body.operationId,
|
|
257
|
+
state: result.body.state,
|
|
258
|
+
action: result.body.action,
|
|
259
|
+
...(canonical ? { operation: projectOperationForChat(canonical) } : {}),
|
|
260
|
+
note: "operation accepted; a durable Chat operation reference will follow canonical status",
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const body = result.body;
|
|
265
|
+
const ok = body.ok === true;
|
|
266
|
+
if (ok) {
|
|
267
|
+
const raw = body.result ?? body;
|
|
268
|
+
return {
|
|
269
|
+
ok: true,
|
|
270
|
+
toolName: decision.toolId,
|
|
271
|
+
result: decision.toolId === "prepareDagConfirmation" || decision.toolId === "prepareMutationGate"
|
|
272
|
+
? redactHumanGateToken(raw)
|
|
273
|
+
: raw,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
const error = body.error ?? {};
|
|
277
|
+
return {
|
|
278
|
+
ok: false,
|
|
279
|
+
toolName: decision.toolId,
|
|
280
|
+
errorCode: typeof error.code === "string" ? error.code : "INTERNAL_ERROR",
|
|
281
|
+
message: typeof error.message === "string"
|
|
282
|
+
? error.message
|
|
283
|
+
: `operator action ${decision.toolId} failed`,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Drive one full Chat turn: persist the user message, run the Pi prompt, and
|
|
288
|
+
* surface events to the caller (SSE). Tool-call events are dispatched through
|
|
289
|
+
* dispatchChatToolCall (Gate 3). The assistant's final message is persisted.
|
|
290
|
+
*
|
|
291
|
+
* The dispatcher wiring (connecting the Pi session's tool registry to
|
|
292
|
+
* dispatchChatToolCall) is performed by the runtime's customTools binding,
|
|
293
|
+
* configured at session-create time by chat-session.ts. This helper is the
|
|
294
|
+
* orchestrator the HTTP route calls.
|
|
295
|
+
*/
|
|
296
|
+
export async function runChatTurn(options) {
|
|
297
|
+
const { runtime, store, sessionId, text } = options;
|
|
298
|
+
if (!runtime.hasSession(sessionId)) {
|
|
299
|
+
return {
|
|
300
|
+
ok: false,
|
|
301
|
+
error: {
|
|
302
|
+
code: "NOT_FOUND",
|
|
303
|
+
message: `chat session not active: ${sessionId}`,
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
await store.appendMessage(sessionId, { role: "user", text });
|
|
308
|
+
let turnError;
|
|
309
|
+
// Collect persistence promises so runChatTurn does not resolve before the
|
|
310
|
+
// chat history / audit trail is durably written (also makes the outcome
|
|
311
|
+
// observable to tests and to a reconnecting client reading the record).
|
|
312
|
+
const pending = [];
|
|
313
|
+
let settledEvent;
|
|
314
|
+
try {
|
|
315
|
+
await runtime.prompt(sessionId, text, (event) => {
|
|
316
|
+
if (event.type === "agent_settled") {
|
|
317
|
+
// The durable operation-ref linker may perform an async canonical
|
|
318
|
+
// operation read. Hold settled until those writes finish so replay
|
|
319
|
+
// never observes agent_settled before its accepted operation ref.
|
|
320
|
+
settledEvent = event;
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
options.onEvent?.(event);
|
|
324
|
+
}
|
|
325
|
+
switch (event.type) {
|
|
326
|
+
case "message_update":
|
|
327
|
+
case "message_end":
|
|
328
|
+
if (event.type === "message_end") {
|
|
329
|
+
pending.push(store.appendMessage(sessionId, {
|
|
330
|
+
role: "assistant",
|
|
331
|
+
text: event.text,
|
|
332
|
+
}));
|
|
333
|
+
}
|
|
334
|
+
break;
|
|
335
|
+
case "tool_call":
|
|
336
|
+
// Forwarded to the caller (SSE) for UI feedback. The actual tool
|
|
337
|
+
// execution happens in the SDK's customTools[].execute(), which
|
|
338
|
+
// routes to dispatchChatToolCall (Gate 3). Do NOT dispatch here —
|
|
339
|
+
// that would double-execute every tool call (P0.2).
|
|
340
|
+
break;
|
|
341
|
+
case "tool_result": {
|
|
342
|
+
const acceptedOperationId = acceptedOperationIdFromResult(event.toolName, event.result);
|
|
343
|
+
if (acceptedOperationId && options.onOperationAccepted) {
|
|
344
|
+
pending.push(Promise.resolve(options.onOperationAccepted({
|
|
345
|
+
sessionId,
|
|
346
|
+
toolCallId: event.toolCallId,
|
|
347
|
+
toolName: event.toolName,
|
|
348
|
+
operationId: acceptedOperationId,
|
|
349
|
+
})));
|
|
350
|
+
}
|
|
351
|
+
// tool_execution_end carries the real result of the tool call.
|
|
352
|
+
// Persist exactly once (the tool message + audit trail). This is
|
|
353
|
+
// the single point of record for tool outcomes (P0.2).
|
|
354
|
+
const resultText = safeStringifyResult(event.result);
|
|
355
|
+
// ChatSessionStore serializes writes per session, so these two calls
|
|
356
|
+
// are safe to fire concurrently — neither clobbers the other.
|
|
357
|
+
pending.push(store.appendMessage(sessionId, {
|
|
358
|
+
role: "tool",
|
|
359
|
+
text: resultText,
|
|
360
|
+
toolCallId: event.toolCallId,
|
|
361
|
+
toolName: event.toolName,
|
|
362
|
+
isError: event.isError,
|
|
363
|
+
}));
|
|
364
|
+
pending.push(store.recordToolInvocation(sessionId, {
|
|
365
|
+
toolCallId: event.toolCallId,
|
|
366
|
+
toolName: event.toolName,
|
|
367
|
+
ok: !event.isError,
|
|
368
|
+
}));
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
case "error":
|
|
372
|
+
turnError = { code: "CHAT_TURN_ERROR", message: event.message };
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
}, { signal: options.signal, images: options.images });
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
await Promise.allSettled(pending);
|
|
379
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
380
|
+
return { ok: false, error: { code: "CHAT_TURN_FAILED", message } };
|
|
381
|
+
}
|
|
382
|
+
await Promise.allSettled(pending);
|
|
383
|
+
if (settledEvent)
|
|
384
|
+
options.onEvent?.(settledEvent);
|
|
385
|
+
return { ok: !turnError, error: turnError };
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Stringify a tool result for persistence. Bounds the stored text length so a
|
|
389
|
+
* huge operator-action payload (e.g. a full dagReport) cannot bloat the chat
|
|
390
|
+
* history record.
|
|
391
|
+
*/
|
|
392
|
+
function acceptedOperationIdFromResult(toolName, result) {
|
|
393
|
+
const candidates = [result];
|
|
394
|
+
if (result && typeof result === "object") {
|
|
395
|
+
const record = result;
|
|
396
|
+
candidates.push(record.details);
|
|
397
|
+
if (Array.isArray(record.content)) {
|
|
398
|
+
for (const item of record.content) {
|
|
399
|
+
if (!item || typeof item !== "object")
|
|
400
|
+
continue;
|
|
401
|
+
const text = item.text;
|
|
402
|
+
if (typeof text !== "string" || text.length > 16_000)
|
|
403
|
+
continue;
|
|
404
|
+
try {
|
|
405
|
+
candidates.push(JSON.parse(text));
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
// Only the controlled JSON tool-result envelope is accepted.
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
for (const candidate of candidates) {
|
|
414
|
+
if (!candidate || typeof candidate !== "object")
|
|
415
|
+
continue;
|
|
416
|
+
const accepted = candidate;
|
|
417
|
+
if (accepted.schemaVersion === 1 &&
|
|
418
|
+
accepted.accepted === true &&
|
|
419
|
+
accepted.action === toolName &&
|
|
420
|
+
typeof accepted.operationId === "string" &&
|
|
421
|
+
accepted.operationId.trim()) {
|
|
422
|
+
return accepted.operationId.trim();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return undefined;
|
|
426
|
+
}
|
|
427
|
+
function safeStringifyResult(result) {
|
|
428
|
+
try {
|
|
429
|
+
const raw = JSON.stringify(result ?? {});
|
|
430
|
+
const { scrubbed } = scrubSecrets(raw);
|
|
431
|
+
return scrubbed.length > 4000 ? `${scrubbed.slice(0, 4000)}…[truncated]` : scrubbed;
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
const { scrubbed } = scrubSecrets(String(result));
|
|
435
|
+
return scrubbed.length > 4000 ? `${scrubbed.slice(0, 4000)}…[truncated]` : scrubbed;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const SHORTCUTS = [
|
|
2
|
+
{ command: "/task", label: "Task context", description: "查看当前任务、Draft 与 assessment", action: "focus-panel:task" },
|
|
3
|
+
{ command: "/contract", label: "Contract", description: "查看 contract diff 与 apply gate", action: "focus-panel:contract" },
|
|
4
|
+
{ command: "/dag", label: "DAG", description: "查看 DAG spine、确认与运行状态", action: "focus-panel:dag" },
|
|
5
|
+
{ command: "/interview", label: "Interview", description: "打开 Requirement Interview", action: "focus:interview" },
|
|
6
|
+
{ command: "/help", label: "Shortcuts", description: "显示可用 shortcut", action: "show-help" },
|
|
7
|
+
];
|
|
8
|
+
/** Pure navigation/display resolver. It deliberately has no mutation decisions. */
|
|
9
|
+
export function resolveShortcut(input) {
|
|
10
|
+
const token = input.trim().split(/\s+/, 1)[0]?.toLowerCase();
|
|
11
|
+
if (!token?.startsWith("/"))
|
|
12
|
+
return undefined;
|
|
13
|
+
const found = SHORTCUTS.find((shortcut) => shortcut.command === token);
|
|
14
|
+
return found ? { command: found.command, action: found.action } : { command: token, action: "unknown" };
|
|
15
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator Chat — operator action → Pi tool schema adapter (roadmap M0-C).
|
|
3
|
+
*
|
|
4
|
+
* The capabilities registry (src/shared/operator/capabilities.ts) is the
|
|
5
|
+
* SINGLE source of truth for each action's typed input schema AND its model
|
|
6
|
+
* policy (modelCallable / humanConfirmation). This adapter renders that
|
|
7
|
+
* registry into the descriptor shape the Pi model invokes; it no longer
|
|
8
|
+
* maintains a second hand-written INPUT_PARAMS table (that drift risk is gone).
|
|
9
|
+
*
|
|
10
|
+
* High-risk / long-running actions ARE mapped as tools (2026-07-25 widening),
|
|
11
|
+
* but the registry now declares their modelCallable policy:
|
|
12
|
+
* - "always": model can invoke; dispatch runs server-side.
|
|
13
|
+
* - "prepare-only": model can invoke but the mutation needs a human-origin
|
|
14
|
+
* confirmation (e.g. runDag needs a confirmationId consumed by a human
|
|
15
|
+
* confirm via the browser mutation gate — M0-B).
|
|
16
|
+
* - "never": the action is the human confirmation itself (confirmDagConfirmation)
|
|
17
|
+
* and MUST NOT appear as a model-callable tool (M0-B / roadmap G03).
|
|
18
|
+
*
|
|
19
|
+
* The adapter does NOT register tools with the SDK directly — that is the
|
|
20
|
+
* session layer's job (chat/pi-runtime.ts). This keeps the adapter pure and
|
|
21
|
+
* unit-testable against the registry.
|
|
22
|
+
*/
|
|
23
|
+
import { buildOperatorCapabilitiesDocument } from "../../../shared/operator/capabilities.js";
|
|
24
|
+
/**
|
|
25
|
+
* Executable mutation faces reserved for browser HumanGate routes.
|
|
26
|
+
*
|
|
27
|
+
* Derived from the capabilities registry: any action that still requires human
|
|
28
|
+
* confirmation (and is not itself the confirm step with modelCallable=never)
|
|
29
|
+
* must NOT be registered as a model-executable tool. The model may only call
|
|
30
|
+
* prepare helpers (prepareDagConfirmation / prepareMutationGate); the browser
|
|
31
|
+
* mutation gate + HMAC token perform the consuming dispatch.
|
|
32
|
+
*
|
|
33
|
+
* `contractApply` is also gate-only even though its registry face remains
|
|
34
|
+
* `humanConfirmation: "conditional"` (assessment soft-gate for workspace UI);
|
|
35
|
+
* Chat executes it only via the contract-apply Human Gate route.
|
|
36
|
+
*/
|
|
37
|
+
const EXPLICIT_EXECUTABLE_GATE_ONLY = ["contractApply"];
|
|
38
|
+
export function listExecutableGateOnlyActions() {
|
|
39
|
+
const derived = buildOperatorCapabilitiesDocument()
|
|
40
|
+
.actions.filter((action) => action.humanConfirmation === "required" &&
|
|
41
|
+
action.modelCallable !== "never")
|
|
42
|
+
.map((action) => action.action);
|
|
43
|
+
return [...new Set([...EXPLICIT_EXECUTABLE_GATE_ONLY, ...derived])].sort();
|
|
44
|
+
}
|
|
45
|
+
/** Frozen snapshot for tests / drift checks (recomputed from registry). */
|
|
46
|
+
export const EXECUTABLE_GATE_ONLY_ACTIONS = Object.freeze(listExecutableGateOnlyActions());
|
|
47
|
+
const EXECUTABLE_GATE_ONLY = new Set(EXECUTABLE_GATE_ONLY_ACTIONS);
|
|
48
|
+
/**
|
|
49
|
+
* Build the FULL Chat tool schema set from the capabilities registry — every
|
|
50
|
+
* registry action, INCLUDING modelCallable="never" ones (e.g.
|
|
51
|
+
* confirmDagConfirmation). Used by internal audits / capability surfaces that
|
|
52
|
+
* need to see the complete registry. The session layer uses
|
|
53
|
+
* buildModelCallableToolSchemas() to render only model-callable tools.
|
|
54
|
+
*/
|
|
55
|
+
export function buildOperatorChatToolSchemas() {
|
|
56
|
+
const doc = buildOperatorCapabilitiesDocument();
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const action of doc.actions) {
|
|
59
|
+
// Fail-closed: every registry action MUST carry inputParams. An action
|
|
60
|
+
// missing inputParams is a contract gap (the model could call it but we
|
|
61
|
+
// don't know which params its dispatcher reads). Throw at boot so the gap
|
|
62
|
+
// is caught in tests and never ships.
|
|
63
|
+
if (!Array.isArray(action.inputParams)) {
|
|
64
|
+
throw new Error(`Operator Chat tool schema gap: registry action "${action.action}" has no inputParams. Add inputParams (must match the dispatcher's paramsOf reads) in src/shared/operator/capabilities.ts.`);
|
|
65
|
+
}
|
|
66
|
+
out.push({
|
|
67
|
+
toolId: action.action,
|
|
68
|
+
action: action.action,
|
|
69
|
+
description: action.description,
|
|
70
|
+
cli: action.cli,
|
|
71
|
+
kind: action.kind,
|
|
72
|
+
inputParams: action.inputParams.map((p) => ({
|
|
73
|
+
name: p.name,
|
|
74
|
+
type: p.type,
|
|
75
|
+
required: p.required,
|
|
76
|
+
description: p.description,
|
|
77
|
+
...(p.itemsType ? { itemsType: p.itemsType } : {}),
|
|
78
|
+
})),
|
|
79
|
+
modelCallable: action.modelCallable,
|
|
80
|
+
humanConfirmation: action.humanConfirmation,
|
|
81
|
+
...(action.resultPolicy ? { resultPolicy: action.resultPolicy } : {}),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Build ONLY the model-callable tool schemas — i.e. exclude
|
|
88
|
+
* modelCallable="never" actions (roadmap M0-B / G03). This is the set the Pi
|
|
89
|
+
* session actually registers as custom tools: the human confirmation action
|
|
90
|
+
* (confirmDagConfirmation) is NOT a model tool; it can only be invoked by the
|
|
91
|
+
* browser mutation gate with a server-signed confirmation token.
|
|
92
|
+
*/
|
|
93
|
+
export function buildModelCallableToolSchemas() {
|
|
94
|
+
return buildOperatorChatToolSchemas().filter((s) => s.modelCallable !== "never" && !EXECUTABLE_GATE_ONLY.has(s.action));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Names of actions that MUST NOT be model-callable (roadmap G03 / G14).
|
|
98
|
+
* Derived from the registry so adding a "never" policy there is the only edit
|
|
99
|
+
* needed. Used by the three-gate tool whitelist to deny model invocation.
|
|
100
|
+
*/
|
|
101
|
+
export function modelForbiddenActions() {
|
|
102
|
+
return buildOperatorChatToolSchemas()
|
|
103
|
+
.filter((s) => s.modelCallable === "never" || EXECUTABLE_GATE_ONLY.has(s.action))
|
|
104
|
+
.map((s) => s.action);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Verify the adapter covers exactly the registry and nothing more.
|
|
108
|
+
* Used by boot/red-team checks (V15 — the full registry is schema'd).
|
|
109
|
+
*/
|
|
110
|
+
export function verifyOperatorChatToolSurface() {
|
|
111
|
+
const schemas = buildOperatorChatToolSchemas();
|
|
112
|
+
const doc = buildOperatorCapabilitiesDocument();
|
|
113
|
+
const covered = schemas.map((s) => s.action).sort();
|
|
114
|
+
const expected = doc.actions.map((a) => a.action).sort();
|
|
115
|
+
const coveredSet = new Set(covered);
|
|
116
|
+
const expectedSet = new Set(expected);
|
|
117
|
+
const missing = expected.filter((a) => !coveredSet.has(a));
|
|
118
|
+
const extra = covered.filter((a) => !expectedSet.has(a));
|
|
119
|
+
return {
|
|
120
|
+
ok: missing.length === 0 && extra.length === 0,
|
|
121
|
+
covered,
|
|
122
|
+
missing,
|
|
123
|
+
extra,
|
|
124
|
+
};
|
|
125
|
+
}
|