@tea-agent/loop-agent 0.23.1 → 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/CHANGELOG.md +18 -1
- package/README.md +1 -1
- package/bin/agent-worker.js +0 -0
- package/dist/executors/shell-executor.js +20 -7
- package/dist/shared/operator/capabilities.js +475 -2
- package/dist/worker/console/app-data.js +2 -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/interview-adapter.js +136 -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 +581 -43
- 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 +18 -17
- package/dist/worker/console/chat/routes.js +1354 -65
- 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 +210 -11
- package/dist/worker/console/chat/shortcuts.js +15 -0
- package/dist/worker/console/chat/tool-adapter.js +81 -194
- package/dist/worker/console/chat/tools.js +72 -48
- 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 +14 -2
- 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/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 +2 -1
- package/docs/README.md +1 -1
- package/docs/architecture/README.md +5 -5
- package/docs/architecture/evolution.md +4 -4
- package/docs/architecture/worker-and-feature.md +1 -1
- package/docs/templates/backend-test-dag.json +2 -2
- package/harness.json +1 -1
- package/package.json +1 -1
- package/dist/worker/console/static/assets/index-DVl7Jxt5.js +0 -25
- package/dist/worker/console/static/assets/index-lVcIr9Ju.css +0 -1
|
@@ -1,230 +1,117 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Operator Chat — operator action → Pi tool schema adapter (
|
|
2
|
+
* Operator Chat — operator action → Pi tool schema adapter (roadmap M0-C).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - inputParams (the parameters the model may supply; MUST match the
|
|
10
|
-
* dispatcher's paramsOf reads — see INPUT_PARAMS contract note)
|
|
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).
|
|
11
9
|
*
|
|
12
|
-
* High-risk / long-running actions ARE mapped as tools (2026-07-25 widening)
|
|
13
|
-
*
|
|
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).
|
|
14
18
|
*
|
|
15
19
|
* The adapter does NOT register tools with the SDK directly — that is the
|
|
16
20
|
* session layer's job (chat/pi-runtime.ts). This keeps the adapter pure and
|
|
17
21
|
* unit-testable against the registry.
|
|
18
22
|
*/
|
|
19
23
|
import { buildOperatorCapabilitiesDocument } from "../../../shared/operator/capabilities.js";
|
|
20
|
-
import { OPERATOR_CHAT_ALLOWED_TOOLS, authorizeOperatorChatTool, } from "./tools.js";
|
|
21
24
|
/**
|
|
22
|
-
*
|
|
23
|
-
* allowed to supply are listed; the dispatch layer fills the rest (repoRoot,
|
|
24
|
-
* operatorSessionId, request identity / clientRequestId — note the dispatcher
|
|
25
|
-
* reads `req.clientRequestId`, not `p.clientRequestId`, so the model MUST NOT
|
|
26
|
-
* be asked for it). Kept intentionally small to avoid over-permissive tool
|
|
27
|
-
* surfaces.
|
|
25
|
+
* Executable mutation faces reserved for browser HumanGate routes.
|
|
28
26
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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.
|
|
34
36
|
*/
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
]
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
{ name: "taskId", type: "string", required: true, description: "task id" },
|
|
47
|
-
{
|
|
48
|
-
name: "draftJson",
|
|
49
|
-
type: "string",
|
|
50
|
-
required: false,
|
|
51
|
-
description: "TaskContractDraftV1 serialized as a JSON string. When omitted, the dispatcher validates the task's currently-saved Console draft.",
|
|
52
|
-
},
|
|
53
|
-
],
|
|
54
|
-
contractDiff: [
|
|
55
|
-
{ name: "taskId", type: "string", required: true, description: "task id" },
|
|
56
|
-
{
|
|
57
|
-
name: "draftJson",
|
|
58
|
-
type: "string",
|
|
59
|
-
required: false,
|
|
60
|
-
description: "TaskContractDraftV1 serialized as a JSON string to diff against the saved draft. When omitted, diffs the saved draft.",
|
|
61
|
-
},
|
|
62
|
-
],
|
|
63
|
-
sourceInstructions: [
|
|
64
|
-
{ name: "taskId", type: "string", required: true, description: "task id" },
|
|
65
|
-
],
|
|
66
|
-
prepareDagConfirmation: [
|
|
67
|
-
{ name: "taskId", type: "string", required: true, description: "task id" },
|
|
68
|
-
],
|
|
69
|
-
observeLink: [
|
|
70
|
-
{
|
|
71
|
-
name: "taskId",
|
|
72
|
-
type: "string",
|
|
73
|
-
required: false,
|
|
74
|
-
description: "task id (optional, one of taskId/dagRunId/featureId)",
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
name: "dagRunId",
|
|
78
|
-
type: "string",
|
|
79
|
-
required: false,
|
|
80
|
-
description: "dag run id",
|
|
81
|
-
},
|
|
82
|
-
{
|
|
83
|
-
name: "featureId",
|
|
84
|
-
type: "string",
|
|
85
|
-
required: false,
|
|
86
|
-
description: "feature id",
|
|
87
|
-
},
|
|
88
|
-
],
|
|
89
|
-
statsContext: [],
|
|
90
|
-
dagReport: [
|
|
91
|
-
{
|
|
92
|
-
name: "runId",
|
|
93
|
-
type: "string",
|
|
94
|
-
required: true,
|
|
95
|
-
description: "dag run id",
|
|
96
|
-
},
|
|
97
|
-
],
|
|
98
|
-
status: [
|
|
99
|
-
{ name: "taskId", type: "string", required: true, description: "task id" },
|
|
100
|
-
],
|
|
101
|
-
spineCheck: [
|
|
102
|
-
{ name: "taskId", type: "string", required: true, description: "task id" },
|
|
103
|
-
],
|
|
104
|
-
dagValidate: [
|
|
105
|
-
{
|
|
106
|
-
name: "dagPath",
|
|
107
|
-
type: "string",
|
|
108
|
-
required: true,
|
|
109
|
-
description: "path to DAG json",
|
|
110
|
-
},
|
|
111
|
-
],
|
|
112
|
-
dagRerunPlan: [
|
|
113
|
-
{
|
|
114
|
-
name: "runId",
|
|
115
|
-
type: "string",
|
|
116
|
-
required: true,
|
|
117
|
-
description: "dag run id",
|
|
118
|
-
},
|
|
119
|
-
{
|
|
120
|
-
name: "fromNode",
|
|
121
|
-
type: "string",
|
|
122
|
-
required: true,
|
|
123
|
-
description: "node id to rerun from",
|
|
124
|
-
},
|
|
125
|
-
],
|
|
126
|
-
// high-risk mutation — formerly human-confirmation-only
|
|
127
|
-
importPrd: [
|
|
128
|
-
{ name: "taskId", type: "string", required: true, description: "task id" },
|
|
129
|
-
{ name: "content", type: "string", required: true, description: "PRD markdown content" },
|
|
130
|
-
],
|
|
131
|
-
contractApply: [
|
|
132
|
-
{ name: "taskId", type: "string", required: true, description: "task id" },
|
|
133
|
-
{ name: "assessmentId", type: "string", required: false, description: "assessment id binding (defaults to the draft's bound assessment)" },
|
|
134
|
-
{ name: "expectedRevision", type: "string", required: false, description: "expected contract revision (defaults to live observed)" },
|
|
135
|
-
{ name: "expectedObservedHash", type: "string", required: false, description: "expected observed hash (defaults to live observed)" },
|
|
136
|
-
],
|
|
137
|
-
confirmDagConfirmation: [
|
|
138
|
-
{ name: "confirmationId", type: "string", required: true, description: "confirmation id from prepareDagConfirmation" },
|
|
139
|
-
{ name: "challenges", type: "array", required: true, itemsType: "string", description: "all challenge answers" },
|
|
140
|
-
],
|
|
141
|
-
workerTaskRetry: [
|
|
142
|
-
{ name: "taskId", type: "string", required: true, description: "task id" },
|
|
143
|
-
{ name: "featureId", type: "string", required: false, description: "feature id (canonical retry carries Feature identity; passed to the CLI as --feature-id when present)" },
|
|
144
|
-
{ name: "reason", type: "string", required: true, description: "retry reason" },
|
|
145
|
-
],
|
|
146
|
-
// long-running execution — formerly human-confirmation-only
|
|
147
|
-
dagRunTask: [
|
|
148
|
-
{ name: "taskId", type: "string", required: true, description: "task id to run as a DAG" },
|
|
149
|
-
{ name: "profile", type: "string", required: false, description: "DAG profile (defaults to auto)" },
|
|
150
|
-
],
|
|
151
|
-
runDag: [
|
|
152
|
-
{ name: "confirmationId", type: "string", required: true, description: "confirmation id (from prepareDagConfirmation) — raw dag path is never accepted from the model" },
|
|
153
|
-
],
|
|
154
|
-
dagRerun: [
|
|
155
|
-
{ name: "runId", type: "string", required: true, description: "dag run id" },
|
|
156
|
-
{ name: "fromNode", type: "string", required: true, description: "node id to rerun from" },
|
|
157
|
-
{ name: "planHash", type: "string", required: true, description: "rerun plan hash" },
|
|
158
|
-
{ name: "reason", type: "string", required: true, description: "rerun reason" },
|
|
159
|
-
],
|
|
160
|
-
standaloneTaskRerun: [
|
|
161
|
-
{ name: "runId", type: "string", required: true, description: "dag run id" },
|
|
162
|
-
{ name: "reason", type: "string", required: true, description: "rerun reason" },
|
|
163
|
-
],
|
|
164
|
-
newTask: [
|
|
165
|
-
{ name: "taskId", type: "string", required: true, description: "task id" },
|
|
166
|
-
{ name: "title", type: "string", required: false, description: "task title (defaults to taskId)" },
|
|
167
|
-
],
|
|
168
|
-
contractDraftSave: [
|
|
169
|
-
{ name: "taskId", type: "string", required: false, description: "task id (defaults to draft.taskId)" },
|
|
170
|
-
{
|
|
171
|
-
name: "draft",
|
|
172
|
-
type: "object",
|
|
173
|
-
required: true,
|
|
174
|
-
description: "TaskContractDraftV1 object",
|
|
175
|
-
},
|
|
176
|
-
],
|
|
177
|
-
};
|
|
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);
|
|
178
48
|
/**
|
|
179
|
-
* Build the
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
* means we don't know which params its dispatcher reads, so exposing it as a
|
|
185
|
-
* zero-param tool would be a silent schema degradation (the model could call
|
|
186
|
-
* it but couldn't supply required fields). Instead we throw at boot so the
|
|
187
|
-
* gap is caught in tests and never ships. Diagnostics list the offending
|
|
188
|
-
* actions so adding a registry action is a one-line INPUT_PARAMS update.
|
|
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.
|
|
189
54
|
*/
|
|
190
55
|
export function buildOperatorChatToolSchemas() {
|
|
191
56
|
const doc = buildOperatorCapabilitiesDocument();
|
|
192
57
|
const out = [];
|
|
193
|
-
const unschemaed = [];
|
|
194
58
|
for (const action of doc.actions) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
if (!
|
|
200
|
-
|
|
201
|
-
// gap, not a zero-param tool. Collect then throw so boot/red-team tests
|
|
202
|
-
// surface it loudly.
|
|
203
|
-
unschemaed.push(action.action);
|
|
204
|
-
continue;
|
|
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.`);
|
|
205
65
|
}
|
|
206
66
|
out.push({
|
|
207
|
-
toolId:
|
|
67
|
+
toolId: action.action,
|
|
208
68
|
action: action.action,
|
|
209
69
|
description: action.description,
|
|
210
70
|
cli: action.cli,
|
|
211
71
|
kind: action.kind,
|
|
212
|
-
inputParams:
|
|
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 } : {}),
|
|
213
82
|
});
|
|
214
83
|
}
|
|
215
|
-
if (unschemaed.length > 0) {
|
|
216
|
-
throw new Error(`Operator Chat tool schema gap: allowed operator actions lack an INPUT_PARAMS entry: ${unschemaed.sort().join(", ")}. Add each to INPUT_PARAMS in tool-adapter.ts (must match the dispatcher's paramsOf reads).`);
|
|
217
|
-
}
|
|
218
84
|
return out;
|
|
219
85
|
}
|
|
220
86
|
/**
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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).
|
|
223
109
|
*/
|
|
224
110
|
export function verifyOperatorChatToolSurface() {
|
|
225
111
|
const schemas = buildOperatorChatToolSchemas();
|
|
112
|
+
const doc = buildOperatorCapabilitiesDocument();
|
|
226
113
|
const covered = schemas.map((s) => s.action).sort();
|
|
227
|
-
const expected =
|
|
114
|
+
const expected = doc.actions.map((a) => a.action).sort();
|
|
228
115
|
const coveredSet = new Set(covered);
|
|
229
116
|
const expectedSet = new Set(expected);
|
|
230
117
|
const missing = expected.filter((a) => !coveredSet.has(a));
|
|
@@ -1,31 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Operator Chat — tool whitelist (design §7.5 /
|
|
2
|
+
* Operator Chat — closed tool whitelist (design §7.5 / roadmap M0).
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* shell / coding-chat) — those can never be activated.
|
|
4
|
+
* The Chat exposes the model-callable structured operator surface. Repository
|
|
5
|
+
* exploration is deliberately limited to safe-read/safe-grep/git-status/
|
|
6
|
+
* git-diff plus built-in find/ls; the bare SDK bash/read/grep tools are denied
|
|
7
|
+
* because they bypass the M0-A write and sensitive-data boundaries.
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* "whitelist/registry drift" risk (old plan D3 W5 constraint) is eliminated.
|
|
15
|
-
* The only hand-maintained list is the DENY list of file-writing tools.
|
|
16
|
-
*
|
|
17
|
-
* bash is ALLOWED but bare (no command filter). The residual risk that bash
|
|
18
|
-
* can still write files via `echo >` / `tee` / `sed -i` is accepted by the
|
|
19
|
-
* owner and documented in the exec-plan Open Questions Q9. A future
|
|
20
|
-
* command-filter layer can close that gap without changing this contract.
|
|
9
|
+
* Dynamic discovery derives model-callable operator actions directly from the
|
|
10
|
+
* capabilities registry. Explicit denial remains the defense-in-depth layer:
|
|
11
|
+
* a future resource-loader or SDK drift cannot reactivate removed coding or
|
|
12
|
+
* explore tools merely by changing the active-tool list.
|
|
21
13
|
*/
|
|
22
14
|
import { buildOperatorCapabilitiesDocument } from "../../../shared/operator/capabilities.js";
|
|
23
15
|
import { OFFICIAL_DENIED_TOOL_IDS } from "../resource-loader.js";
|
|
16
|
+
import { EXECUTABLE_GATE_ONLY_ACTIONS } from "./tool-adapter.js";
|
|
17
|
+
const EXECUTABLE_GATE_ONLY = new Set(EXECUTABLE_GATE_ONLY_ACTIONS);
|
|
18
|
+
/**
|
|
19
|
+
* Actions the model MAY invoke. Derived from the registry but filtered by the
|
|
20
|
+
* modelCallable policy: modelCallable="never" actions (e.g. confirmDagConfirmation)
|
|
21
|
+
* are the human confirmation itself and MUST NOT be model-callable (roadmap
|
|
22
|
+
* M0-B / G03). They are never registered as Pi tools AND denied at the gate.
|
|
23
|
+
*/
|
|
24
|
+
export const OPERATOR_CHAT_ALLOWED_TOOLS = Object.freeze(buildOperatorCapabilitiesDocument()
|
|
25
|
+
.actions.filter((a) => a.modelCallable !== "never" && !EXECUTABLE_GATE_ONLY.has(a.action))
|
|
26
|
+
.map((a) => a.action));
|
|
24
27
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
28
|
+
* Operator actions that MUST NOT be model-callable (roadmap M0-B / G03).
|
|
29
|
+
* Derived from the registry's modelCallable="never" policy. These are the
|
|
30
|
+
* human confirmation actions (confirmDagConfirmation) — the model can only
|
|
31
|
+
* prepare them; the browser mutation gate executes the confirm with a
|
|
32
|
+
* server-signed confirmation token.
|
|
27
33
|
*/
|
|
28
|
-
export const
|
|
34
|
+
export const OPERATOR_CHAT_MODEL_FORBIDDEN_ACTIONS = Object.freeze(buildOperatorCapabilitiesDocument()
|
|
35
|
+
.actions.filter((a) => a.modelCallable === "never" || EXECUTABLE_GATE_ONLY.has(a.action))
|
|
36
|
+
.map((a) => a.action));
|
|
29
37
|
/**
|
|
30
38
|
* Operator actions denied by the legacy MVP boundary. Kept EMPTY since the
|
|
31
39
|
* owner decided (2026-07-25) that the full operator surface is Chat-callable.
|
|
@@ -33,31 +41,40 @@ export const OPERATOR_CHAT_ALLOWED_TOOLS = Object.freeze(buildOperatorCapabiliti
|
|
|
33
41
|
* stay defensive against future additions.
|
|
34
42
|
*/
|
|
35
43
|
export const OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS = Object.freeze([]);
|
|
36
|
-
// Coding / generic tool ids that must never appear in a Chat session.
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
// tools AND the high-risk operator actions (contractApply / runDag / dagRerun
|
|
43
|
-
// / etc. are now Chat-callable per the widening — OFFICIAL_DENIED_TOOL_IDS
|
|
44
|
-
// listed them as a defense against accidental activation in the Interview
|
|
45
|
-
// flow, but the Chat deliberately exposes the full operator surface).
|
|
46
|
-
const _EXPLORE_TOOLS = new Set(["bash", "read", "grep", "find", "ls"]);
|
|
44
|
+
// Coding / generic tool ids that must never appear in a Chat session. M0-A
|
|
45
|
+
// removes bare bash (write-via-redirect) and SDK read/grep (no sensitive-file
|
|
46
|
+
// boundary); find/ls remain the only SDK explore builtins. Safe content probes
|
|
47
|
+
// are custom tools, not generic SDK tools.
|
|
48
|
+
const _ALLOWED_SDK_EXPLORE_TOOLS = new Set(["find", "ls"]);
|
|
49
|
+
const _REMOVED_SDK_EXPLORE_TOOLS = ["bash", "read", "grep"];
|
|
47
50
|
// Operator actions that OFFICIAL_DENIED_TOOL_IDS carries for Interview safety
|
|
48
51
|
// but that the Chat now deliberately allows. We compare on a COMPACTED form
|
|
49
52
|
// (strip - and _) because OFFICIAL lists both camelCase (`contractApply`) and
|
|
50
53
|
// snake_case (`contract_apply`) variants, and authorize() also normalizes —
|
|
51
54
|
// leaving any variant in would re-deny the action via normalization.
|
|
52
55
|
const _ALLOWED_OPERATOR_ACTIONS_COMPACT = new Set(OPERATOR_CHAT_ALLOWED_TOOLS.map((t) => t.toLowerCase().replace(/[-_]/g, "")));
|
|
53
|
-
export const OPERATOR_CHAT_DENIED_TOOLS = Object.freeze(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
export const OPERATOR_CHAT_DENIED_TOOLS = Object.freeze([
|
|
57
|
+
...new Set([
|
|
58
|
+
...OFFICIAL_DENIED_TOOL_IDS.filter((id) => {
|
|
59
|
+
const lower = id.toLowerCase();
|
|
60
|
+
if (_ALLOWED_SDK_EXPLORE_TOOLS.has(lower))
|
|
61
|
+
return false;
|
|
62
|
+
if (_ALLOWED_OPERATOR_ACTIONS_COMPACT.has(lower.replace(/[-_]/g, ""))) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}),
|
|
67
|
+
..._REMOVED_SDK_EXPLORE_TOOLS,
|
|
68
|
+
]),
|
|
69
|
+
]);
|
|
70
|
+
/**
|
|
71
|
+
* Operator actions denied by model policy (modelCallable="never"). These are
|
|
72
|
+
* human-only actions (the confirmation itself) — derived from the registry so
|
|
73
|
+
* adding a "never" policy there is the only edit needed. Kept separate from
|
|
74
|
+
* OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS (legacy empty MVP boundary list) to
|
|
75
|
+
* preserve the two distinct concerns (model policy vs MVP mutation boundary).
|
|
76
|
+
*/
|
|
77
|
+
const DENIED_MODEL_POLICY = new Set(OPERATOR_CHAT_MODEL_FORBIDDEN_ACTIONS.map((a) => a.toLowerCase()));
|
|
61
78
|
const ALLOWED = new Set(OPERATOR_CHAT_ALLOWED_TOOLS.map((t) => t.toLowerCase()));
|
|
62
79
|
const DENIED_ACTIONS = new Set(OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS.map((a) => a.toLowerCase()));
|
|
63
80
|
const DENIED_TOOLS = new Set(OPERATOR_CHAT_DENIED_TOOLS.map((t) => t.toLowerCase()));
|
|
@@ -70,6 +87,9 @@ export function isOperatorChatToolDenied(toolId) {
|
|
|
70
87
|
return true;
|
|
71
88
|
if (DENIED_ACTIONS.has(id))
|
|
72
89
|
return true;
|
|
90
|
+
// modelCallable="never" actions are always denied to the model (roadmap G03).
|
|
91
|
+
if (DENIED_MODEL_POLICY.has(id))
|
|
92
|
+
return true;
|
|
73
93
|
// normalize snake/camel/kebab
|
|
74
94
|
const compact = id.replace(/[-_]/g, "");
|
|
75
95
|
for (const d of DENIED_TOOLS) {
|
|
@@ -80,6 +100,10 @@ export function isOperatorChatToolDenied(toolId) {
|
|
|
80
100
|
if (d.replace(/[-_]/g, "") === compact)
|
|
81
101
|
return true;
|
|
82
102
|
}
|
|
103
|
+
for (const d of DENIED_MODEL_POLICY) {
|
|
104
|
+
if (d.replace(/[-_]/g, "") === compact)
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
83
107
|
return false;
|
|
84
108
|
}
|
|
85
109
|
/**
|
|
@@ -101,7 +125,7 @@ export function authorizeOperatorChatTool(toolId) {
|
|
|
101
125
|
ok: false,
|
|
102
126
|
code: "tool-denied",
|
|
103
127
|
toolId: id,
|
|
104
|
-
message: `tool "${id}" is denied in General Operator Chat (no edit/write/apply_patch/full-tools/shell/coding-chat)`,
|
|
128
|
+
message: `tool "${id}" is denied in General Operator Chat (no bash/read/grep/edit/write/apply_patch/full-tools/shell/coding-chat)`,
|
|
105
129
|
};
|
|
106
130
|
}
|
|
107
131
|
if (!isOperatorChatToolAllowed(id)) {
|
|
@@ -131,12 +155,9 @@ export function filterOperatorChatTools(requested) {
|
|
|
131
155
|
return { allowed, denied };
|
|
132
156
|
}
|
|
133
157
|
/**
|
|
134
|
-
* Assert that a
|
|
135
|
-
* Used by red-team / boot checks (V12
|
|
136
|
-
*
|
|
137
|
-
* guard now only blocks tools whose PRIMARY purpose is editing files
|
|
138
|
-
* (edit/write/apply_patch/full-tools/shell). The residual write-via-bash risk
|
|
139
|
-
* (`echo >` / `tee`) is documented in exec-plan Open Questions Q9.
|
|
158
|
+
* Assert that a requested tool set contains no prohibited coding or removed
|
|
159
|
+
* SDK explore tool. Used by red-team / boot checks (V12 / M0-A). bash is
|
|
160
|
+
* explicitly forbidden: accepting it would recreate the redirect write escape.
|
|
140
161
|
*/
|
|
141
162
|
export function assertNoWriteToolInList(requested) {
|
|
142
163
|
const writeVariants = [
|
|
@@ -149,6 +170,9 @@ export function assertNoWriteToolInList(requested) {
|
|
|
149
170
|
"coding-chat",
|
|
150
171
|
"coding_chat",
|
|
151
172
|
"shell",
|
|
173
|
+
"bash",
|
|
174
|
+
"read",
|
|
175
|
+
"grep",
|
|
152
176
|
];
|
|
153
177
|
const lower = new Set(requested.map((t) => t.trim().toLowerCase()));
|
|
154
178
|
for (const v of writeVariants) {
|
|
@@ -158,8 +182,8 @@ export function assertNoWriteToolInList(requested) {
|
|
|
158
182
|
}
|
|
159
183
|
}
|
|
160
184
|
/**
|
|
161
|
-
* Back-compat alias kept for callers that still reference the old name.
|
|
162
|
-
*
|
|
185
|
+
* Back-compat alias kept for callers that still reference the old name. It
|
|
186
|
+
* also rejects removed SDK explore tools, including bash.
|
|
163
187
|
* @deprecated use {@link assertNoWriteToolInList}.
|
|
164
188
|
*/
|
|
165
189
|
export function assertNoBashInToolList(requested) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
function record(value) {
|
|
2
|
+
return value && typeof value === "object" ? value : undefined;
|
|
3
|
+
}
|
|
4
|
+
function numberAt(source, keys) {
|
|
5
|
+
for (const key of keys)
|
|
6
|
+
if (typeof source[key] === "number" && Number.isFinite(source[key]))
|
|
7
|
+
return Math.max(0, source[key]);
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
export function extractUsageSample(value) {
|
|
11
|
+
const event = record(value);
|
|
12
|
+
if (!event)
|
|
13
|
+
return undefined;
|
|
14
|
+
const message = record(event.message);
|
|
15
|
+
const source = record(event.usage) ?? record(message?.usage) ?? record(event.tokenUsage);
|
|
16
|
+
if (!source)
|
|
17
|
+
return undefined;
|
|
18
|
+
const inputTokens = numberAt(source, ["input_tokens", "inputTokens", "prompt_tokens", "promptTokens"]);
|
|
19
|
+
const outputTokens = numberAt(source, ["output_tokens", "outputTokens", "completion_tokens", "completionTokens"]);
|
|
20
|
+
const totalTokens = numberAt(source, ["total_tokens", "totalTokens"]) ?? (inputTokens !== undefined || outputTokens !== undefined ? (inputTokens ?? 0) + (outputTokens ?? 0) : undefined);
|
|
21
|
+
if (inputTokens === undefined && outputTokens === undefined && totalTokens === undefined)
|
|
22
|
+
return undefined;
|
|
23
|
+
const responseKey = [event.responseId, message?.id, source.responseId].find((item) => typeof item === "string");
|
|
24
|
+
return { inputTokens, outputTokens, totalTokens, ...(responseKey ? { responseKey } : {}) };
|
|
25
|
+
}
|
|
26
|
+
export function aggregateUsage(samples) {
|
|
27
|
+
const keyed = new Map();
|
|
28
|
+
const anonymous = [];
|
|
29
|
+
for (const sample of samples)
|
|
30
|
+
sample.responseKey ? keyed.set(sample.responseKey, sample) : anonymous.push(sample);
|
|
31
|
+
const selected = [...keyed.values(), ...anonymous];
|
|
32
|
+
return selected.reduce((sum, sample) => ({
|
|
33
|
+
inputTokens: sum.inputTokens + (sample.inputTokens ?? 0), outputTokens: sum.outputTokens + (sample.outputTokens ?? 0), totalTokens: sum.totalTokens + (sample.totalTokens ?? ((sample.inputTokens ?? 0) + (sample.outputTokens ?? 0))),
|
|
34
|
+
cost: sample.cost === undefined ? sum.cost : (sum.cost ?? 0) + sample.cost,
|
|
35
|
+
estimated: Boolean(sum.estimated || sample.estimated),
|
|
36
|
+
}), { inputTokens: 0, outputTokens: 0, totalTokens: 0 });
|
|
37
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const ACTIVE_CHAT_SESSION_STORAGE_KEY = "loop-console.operator-chat.active-session.v1";
|
|
2
|
+
export const DEFAULT_LANDING_STORAGE_KEY = "loop-console.operator-chat.default-landing.v1";
|
|
3
|
+
function configuredLanding(value) {
|
|
4
|
+
const normalized = value?.trim().toLowerCase();
|
|
5
|
+
if (normalized === "chat")
|
|
6
|
+
return "chat";
|
|
7
|
+
if (normalized === "tasks")
|
|
8
|
+
return "tasks";
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
function configuredWorkspace(value) {
|
|
12
|
+
const normalized = value?.trim().toLowerCase();
|
|
13
|
+
if (normalized === "chat" || normalized === "tasks" || normalized === "recovery")
|
|
14
|
+
return normalized;
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
function workspaceFromLocation(location) {
|
|
18
|
+
if (!location)
|
|
19
|
+
return undefined;
|
|
20
|
+
const query = new URLSearchParams(location.search);
|
|
21
|
+
const queryValue = configuredLanding(query.get("landing") ?? query.get("workspace"));
|
|
22
|
+
if (queryValue)
|
|
23
|
+
return queryValue;
|
|
24
|
+
const hash = location.hash.startsWith("#") ? location.hash.slice(1) : location.hash;
|
|
25
|
+
return configuredLanding(new URLSearchParams(hash).get("workspace"));
|
|
26
|
+
}
|
|
27
|
+
export function resolveDefaultLanding(input) {
|
|
28
|
+
return workspaceFromLocation(input.location) ??
|
|
29
|
+
configuredLanding(input.storage?.getItem(DEFAULT_LANDING_STORAGE_KEY)) ??
|
|
30
|
+
configuredLanding(input.env) ??
|
|
31
|
+
"tasks";
|
|
32
|
+
}
|
|
33
|
+
export function buildWorkspaceHref(workspace, params = {}) {
|
|
34
|
+
const query = new URLSearchParams({ workspace });
|
|
35
|
+
for (const [key, value] of Object.entries(params)) {
|
|
36
|
+
if (value?.trim())
|
|
37
|
+
query.set(key, value.trim());
|
|
38
|
+
}
|
|
39
|
+
return `/?${query.toString()}`;
|
|
40
|
+
}
|
|
41
|
+
export function parseWorkspaceHref(location) {
|
|
42
|
+
const query = new URLSearchParams(location.search);
|
|
43
|
+
const hash = location.hash.startsWith("#") ? location.hash.slice(1) : location.hash;
|
|
44
|
+
const hashQuery = new URLSearchParams(hash);
|
|
45
|
+
const workspace = configuredWorkspace(query.get("workspace") ?? query.get("landing") ?? hashQuery.get("workspace"));
|
|
46
|
+
if (!workspace)
|
|
47
|
+
return null;
|
|
48
|
+
const read = (key) => query.get(key)?.trim() || undefined;
|
|
49
|
+
return {
|
|
50
|
+
workspace,
|
|
51
|
+
...(read("taskId") ? { taskId: read("taskId") } : {}),
|
|
52
|
+
...(read("dagRunId") ? { dagRunId: read("dagRunId") } : {}),
|
|
53
|
+
...(read("fromNodeId") ? { fromNodeId: read("fromNodeId") } : {}),
|
|
54
|
+
...(read("featureId") ? { featureId: read("featureId") } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|