@zq-silk/yui 2.1.0 → 2.2.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/README.md +4 -0
- package/dist/cli/commandCatalog.js +24 -3
- package/dist/cli/commandDiscovery.js +4 -1
- package/dist/cli.js +23 -3078
- package/dist/commands/taskCommands.js +79 -21
- package/dist/commands/taskIntegrationCommands.js +3 -1
- package/dist/commands/taskUpstreamCommands.js +3 -1
- package/dist/context/runContextPack.js +132 -5
- package/dist/context/sourceRunContext.js +4 -2
- package/dist/context/taskContext.js +6 -1
- package/dist/controlPlaneCli.js +3092 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +19 -6
- package/dist/controller/jobControl.js +21 -0
- package/dist/executor/agentExecutor.js +1 -1
- package/dist/executor/effectiveLaunch.js +18 -5
- package/dist/executor/fileRoleLaunchPlanner.js +15 -19
- package/dist/integration/gitIntegrationService.js +50 -15
- package/dist/message/messageContinuation.js +7 -2
- package/dist/nativeAgent/agent.js +176 -74
- package/dist/nativeAgent/cliDemo.js +37 -0
- package/dist/nativeAgent/codingTools.js +11 -0
- package/dist/nativeAgent/commandTool.js +215 -0
- package/dist/nativeAgent/compactionDemo.js +158 -0
- package/dist/nativeAgent/composition.js +71 -0
- package/dist/nativeAgent/context/budget.js +44 -0
- package/dist/nativeAgent/context/index.js +339 -0
- package/dist/nativeAgent/context/providerCompressor.js +103 -0
- package/dist/nativeAgent/demo.js +12 -1
- package/dist/nativeAgent/evaluation/cases.js +38 -0
- package/dist/nativeAgent/evaluation/checks.js +91 -0
- package/dist/nativeAgent/evaluation/demo.js +19 -0
- package/dist/nativeAgent/evaluation/files.js +54 -0
- package/dist/nativeAgent/evaluation/fixture.js +36 -0
- package/dist/nativeAgent/evaluation/index.js +239 -0
- package/dist/nativeAgent/executionOwner.js +209 -0
- package/dist/nativeAgent/filePatterns.js +170 -0
- package/dist/nativeAgent/fileToolsSupport.js +202 -0
- package/dist/nativeAgent/index.js +13 -0
- package/dist/nativeAgent/interaction/cli.js +358 -0
- package/dist/nativeAgent/interaction/contracts.js +1 -0
- package/dist/nativeAgent/interaction/index.js +3 -0
- package/dist/nativeAgent/interaction/memoryDemo.js +115 -0
- package/dist/nativeAgent/interaction/renderer.js +34 -0
- package/dist/nativeAgent/localSafety.js +258 -0
- package/dist/nativeAgent/model/anthropicMessages.js +204 -0
- package/dist/nativeAgent/model/chatCompletions.js +210 -0
- package/dist/nativeAgent/model/errors.js +47 -0
- package/dist/nativeAgent/model/gateway.js +423 -0
- package/dist/nativeAgent/model/index.js +7 -0
- package/dist/nativeAgent/model/observationAdapter.js +21 -0
- package/dist/nativeAgent/model/protocols.js +19 -0
- package/dist/nativeAgent/model/responses.js +263 -0
- package/dist/nativeAgent/model/types.js +1 -0
- package/dist/nativeAgent/model/wire.js +73 -0
- package/dist/nativeAgent/observability/index.js +220 -0
- package/dist/nativeAgent/product/catalog.js +82 -0
- package/dist/nativeAgent/product/config.js +295 -0
- package/dist/nativeAgent/product/facts.js +30 -0
- package/dist/nativeAgent/product/index.js +62 -0
- package/dist/nativeAgent/product/location.js +44 -0
- package/dist/nativeAgent/product/runtime.js +276 -0
- package/dist/nativeAgent/product/storage.js +49 -0
- package/dist/nativeAgent/product/tools.js +47 -0
- package/dist/nativeAgent/product/transport.js +54 -0
- package/dist/nativeAgent/projectGuidance/index.js +425 -0
- package/dist/nativeAgent/searchTools.js +305 -0
- package/dist/nativeAgent/session/backends.js +293 -0
- package/dist/nativeAgent/session/catalog.js +97 -0
- package/dist/nativeAgent/session/catalogDemo.js +87 -0
- package/dist/nativeAgent/session/contracts.js +1 -0
- package/dist/nativeAgent/session/format.js +269 -0
- package/dist/nativeAgent/session/index.js +5 -0
- package/dist/nativeAgent/session/location.js +36 -0
- package/dist/nativeAgent/session/sqliteFormat.js +134 -0
- package/dist/nativeAgent/session/store.js +248 -0
- package/dist/nativeAgent/textTools.js +270 -133
- package/dist/nativeAgent/toolManager/executor.js +290 -0
- package/dist/nativeAgent/toolManager/index.js +4 -0
- package/dist/nativeAgent/validation.js +2 -2
- package/dist/task/taskAuthority.js +56 -0
- package/dist/web/assets/client/app.js +64 -3
- package/dist/web/assets/client/detail.js +6 -6
- package/dist/web/assets/client/i18n.js +4 -0
- package/dist/web/assets/client/overview.js +12 -9
- package/dist/web/assets/client/sidebar.js +31 -2
- package/dist/web/assets/shell.js +2 -1
- package/dist/web/assets/styles/components.js +2 -0
- package/dist/web/assets/styles/layout.js +11 -4
- package/dist/web/assets/styles/responsive.js +12 -4
- package/dist/web/assets/styles/views.js +16 -13
- package/docs/agent-result-consumption.md +16 -0
- package/docs/agent-result-consumption.zh-CN.md +13 -0
- package/docs/examples/agent-offline.mjs +194 -0
- package/docs/native-agent.md +283 -0
- package/docs/release-workflow.md +47 -9
- package/docs/release-workflow.zh-CN.md +36 -6
- package/docs/roles-and-configuration.md +32 -0
- package/docs/roles-and-configuration.zh-CN.md +26 -0
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +9 -0
- package/skills/yui-reviewer/SKILL.md +5 -0
- package/skills/yui-runtime/SKILL.md +7 -0
|
@@ -1,74 +1,154 @@
|
|
|
1
|
+
import { createToolExecutor } from './toolManager/index.js';
|
|
2
|
+
import { createContextBuilder, ContextBuildError } from './context/index.js';
|
|
1
3
|
import { AgentFault, history, limits, response, size, snapshot, validOutcome } from './validation.js';
|
|
2
4
|
const failed = (code, message, effect = 'none') => ({ ok: false, error: { code, message, effect } });
|
|
3
5
|
export function createAgent(options) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
throw new Error('
|
|
6
|
+
if (!options.provider || typeof options.provider.complete !== 'function'
|
|
7
|
+
|| (options.tools === undefined) === (options.toolExecutor === undefined)) {
|
|
8
|
+
throw new Error('A provider and exactly one of tools or toolExecutor are required');
|
|
7
9
|
}
|
|
8
|
-
|
|
10
|
+
// tools is an explicitly preauthorized, prebound capability set. This lease
|
|
11
|
+
// owns no resources and does not pretend to narrow those tools' authority.
|
|
12
|
+
const executor = options.toolExecutor ?? createToolExecutor({
|
|
13
|
+
tools: options.tools,
|
|
14
|
+
environment: { async acquire() { return { value: undefined, async release() { } }; } },
|
|
15
|
+
permission: { async check() { return { allowed: true }; } },
|
|
16
|
+
});
|
|
17
|
+
const definitions = snapshot(executor.definitions);
|
|
18
|
+
if (typeof executor.executeBatch !== 'function' || !Array.isArray(definitions)
|
|
19
|
+
|| definitions.some(d => !d || typeof d.name !== 'string' || !d.name.trim())
|
|
20
|
+
|| new Set(definitions.map(d => d.name)).size !== definitions.length) {
|
|
21
|
+
throw new Error('An executor with unique nonempty tool definitions is required');
|
|
22
|
+
}
|
|
23
|
+
const { provider, recorder, observer } = options;
|
|
24
|
+
const contextBuilder = options.contextBuilder ?? createContextBuilder();
|
|
25
|
+
const contextBudget = snapshot(options.contextBudget ?? { capacity: limits.historyBytes, reserveOutput: 0 });
|
|
9
26
|
return {
|
|
10
27
|
async runTurn(input) {
|
|
11
28
|
const scope = { sessionId: input.sessionId, turnId: input.turnId };
|
|
12
29
|
const signal = input.signal ?? new AbortController().signal;
|
|
30
|
+
const { maxSteps, input: userInput } = input;
|
|
13
31
|
const messages = [];
|
|
14
32
|
const events = [];
|
|
33
|
+
const contextReports = [];
|
|
15
34
|
let steps = 0;
|
|
16
35
|
let reason = 'error';
|
|
17
36
|
let error;
|
|
18
|
-
let
|
|
37
|
+
let recording = recorder
|
|
38
|
+
? { status: 'recorded', lastRecordedSeq: 0 } : { status: 'memory', lastRecordedSeq: 0 };
|
|
39
|
+
let recordingFailed = false;
|
|
40
|
+
const observerErrors = [];
|
|
19
41
|
const emit = async (data) => {
|
|
20
|
-
|
|
42
|
+
let event = snapshot({ ...scope, seq: events.length + 1, data });
|
|
21
43
|
events.push(event);
|
|
22
|
-
if (
|
|
44
|
+
if (recorder && !recordingFailed) {
|
|
23
45
|
try {
|
|
24
|
-
await
|
|
46
|
+
await recorder.record(event);
|
|
47
|
+
recording = { status: 'recorded', lastRecordedSeq: event.seq };
|
|
25
48
|
}
|
|
26
|
-
catch {
|
|
27
|
-
|
|
28
|
-
|
|
49
|
+
catch (cause) {
|
|
50
|
+
recordingFailed = true;
|
|
51
|
+
recording = { status: 'failed', lastRecordedSeq: recording.lastRecordedSeq, failedSeq: event.seq };
|
|
52
|
+
error = { code: 'recording_failed',
|
|
53
|
+
message: `Required recording failed: ${cause instanceof Error ? cause.message : String(cause)}; inspect recorder before recovery` };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// A failed terminal write has unknown external persistence. Publish
|
|
57
|
+
// exactly one truthful local terminal to observers and the caller.
|
|
58
|
+
if (data.type === 'turn_ended' && recordingFailed) {
|
|
59
|
+
reason = 'error';
|
|
60
|
+
event = snapshot({ ...event, data: { type: 'turn_ended', reason, errorCode: error.code } });
|
|
61
|
+
events[events.length - 1] = event;
|
|
62
|
+
}
|
|
63
|
+
// Observers enqueue synchronously. Never await display/telemetry delivery.
|
|
64
|
+
// Detach an invalid async consumer and observe its rejection without
|
|
65
|
+
// misrepresenting notification as transport or persistence confirmation.
|
|
66
|
+
if (observer && !observerErrors.length) {
|
|
67
|
+
try {
|
|
68
|
+
const returned = observer.observe(event);
|
|
69
|
+
if (returned && typeof returned.then === 'function') {
|
|
70
|
+
void Promise.resolve(returned).catch(() => { });
|
|
71
|
+
throw new Error('Observer must return synchronously; enqueue asynchronous transport in the consumer');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (cause) {
|
|
75
|
+
observerErrors.push({ seq: event.seq, message: cause instanceof Error ? cause.message : String(cause) });
|
|
29
76
|
}
|
|
30
77
|
}
|
|
31
78
|
};
|
|
32
|
-
const append = async (message, step) => {
|
|
79
|
+
const append = async (message, step, settlement) => {
|
|
33
80
|
const saved = snapshot(message);
|
|
34
81
|
messages.push(saved);
|
|
35
|
-
|
|
82
|
+
const evidence = settlement && { started: settlement.started, status: settlement.status,
|
|
83
|
+
cancellationRequested: settlement.cancellationRequested, cleanup: settlement.cleanup };
|
|
84
|
+
await emit({ type: 'message_appended', step, message: saved, ...(evidence ? { settlement: evidence } : {}) });
|
|
36
85
|
};
|
|
37
86
|
await emit({ type: 'turn_started' });
|
|
38
87
|
try {
|
|
39
88
|
if (!scope.sessionId?.trim() || !scope.turnId?.trim()
|
|
40
|
-
|| !Number.isSafeInteger(
|
|
41
|
-
|| size({ role: 'user', content:
|
|
89
|
+
|| !Number.isSafeInteger(maxSteps) || maxSteps < 1 || typeof userInput !== 'string'
|
|
90
|
+
|| size({ role: 'user', content: userInput }) > limits.messageBytes) {
|
|
42
91
|
throw new AgentFault('invalid_input', 'Nonempty identities, bounded input and positive integer maxSteps required');
|
|
43
92
|
}
|
|
44
|
-
|
|
93
|
+
// Canonical history can exceed the model request budget. The context
|
|
94
|
+
// builder may project it, but must not hide unresolved effects or IDs.
|
|
95
|
+
const prior = history(input.history ?? [], Infinity);
|
|
96
|
+
if (prior.some(m => m.role === 'tool' && !m.outcome.ok && m.outcome.error.effect === 'unknown')) {
|
|
97
|
+
throw new AgentFault('unresolved_effect', 'History contains an unknown tool effect; reconcile it explicitly before continuing');
|
|
98
|
+
}
|
|
45
99
|
const usedIds = new Set(prior.flatMap(m => m.role === 'assistant' ? m.toolCalls.map(c => c.id) : []));
|
|
46
|
-
await append({ role: 'user', content:
|
|
47
|
-
while (!
|
|
100
|
+
await append({ role: 'user', content: userInput });
|
|
101
|
+
while (!recordingFailed) {
|
|
48
102
|
if (signal.aborted) {
|
|
49
103
|
reason = 'cancelled';
|
|
50
104
|
break;
|
|
51
105
|
}
|
|
52
|
-
if (steps >=
|
|
106
|
+
if (steps >= maxSteps) {
|
|
53
107
|
reason = 'budget_exhausted';
|
|
54
108
|
break;
|
|
55
109
|
}
|
|
56
110
|
const all = [...prior, ...messages];
|
|
57
|
-
if (size(all) > limits.historyBytes)
|
|
58
|
-
throw new AgentFault('context_limit', 'History exceeds context byte limit');
|
|
59
111
|
steps++;
|
|
60
112
|
await emit({ type: 'step_started', step: steps });
|
|
61
113
|
try {
|
|
62
|
-
if (
|
|
114
|
+
if (recordingFailed)
|
|
63
115
|
break;
|
|
64
116
|
if (signal.aborted) {
|
|
65
117
|
reason = 'cancelled';
|
|
66
118
|
break;
|
|
67
119
|
}
|
|
68
|
-
const
|
|
120
|
+
const source = snapshot({ ...scope, step: steps, messages: all, tools: definitions });
|
|
121
|
+
let request;
|
|
122
|
+
try {
|
|
123
|
+
const built = await contextBuilder.build({ ...options.contextRetention, ...input.context,
|
|
124
|
+
request: source, budget: contextBudget }, signal);
|
|
125
|
+
if (signal.aborted) {
|
|
126
|
+
reason = 'cancelled';
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
if (built.request.sessionId !== scope.sessionId || built.request.turnId !== scope.turnId
|
|
130
|
+
|| built.request.step !== steps || JSON.stringify(built.request.tools) !== JSON.stringify(definitions)) {
|
|
131
|
+
throw new Error('Context builder changed execution identity or tool definitions');
|
|
132
|
+
}
|
|
133
|
+
request = snapshot({ ...built.request, messages: history(built.request.messages, Infinity) });
|
|
134
|
+
contextReports.push(snapshot({ step: steps, status: 'prepared', report: built.report }));
|
|
135
|
+
}
|
|
136
|
+
catch (cause) {
|
|
137
|
+
if (cause instanceof ContextBuildError && cause.report) {
|
|
138
|
+
contextReports.push(snapshot({ step: steps, status: 'failed', report: cause.report }));
|
|
139
|
+
}
|
|
140
|
+
if (signal.aborted) {
|
|
141
|
+
reason = 'cancelled';
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
throw new AgentFault('context_error', cause instanceof Error ? cause.message : String(cause));
|
|
145
|
+
}
|
|
146
|
+
if (size(request) > limits.historyBytes) {
|
|
147
|
+
throw new AgentFault('context_limit', 'Model request exceeds context byte limit');
|
|
148
|
+
}
|
|
69
149
|
let raw;
|
|
70
150
|
try {
|
|
71
|
-
raw = await
|
|
151
|
+
raw = await provider.complete(request, signal);
|
|
72
152
|
}
|
|
73
153
|
catch (cause) {
|
|
74
154
|
if (signal.aborted) {
|
|
@@ -89,56 +169,85 @@ export function createAgent(options) {
|
|
|
89
169
|
}
|
|
90
170
|
await append({ role: 'assistant', content: model.content, toolCalls: model.calls }, steps);
|
|
91
171
|
let uncertain = false;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
172
|
+
const settled = new Map();
|
|
173
|
+
let batchFailed = false;
|
|
174
|
+
const acceptSettlement = async (settlement) => {
|
|
175
|
+
const call = model.calls[settled.size];
|
|
176
|
+
if (!call || settlement.identity.sessionId !== scope.sessionId
|
|
177
|
+
|| settlement.identity.turnId !== scope.turnId || settlement.identity.step !== steps
|
|
178
|
+
|| settlement.identity.toolCallId !== call.id || settlement.identity.name !== call.name
|
|
179
|
+
|| !validOutcome(settlement.outcome) || typeof settlement.started !== 'boolean'
|
|
180
|
+
|| !['not_acquired', 'released', 'failed', 'acquire_failed'].includes(settlement.cleanup?.status)) {
|
|
181
|
+
throw new Error('Executor returned an invalid or out-of-order settlement');
|
|
97
182
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
183
|
+
const saved = snapshot(settlement);
|
|
184
|
+
settled.set(call.id, saved);
|
|
185
|
+
await append({ role: 'tool', toolCallId: call.id, name: call.name, outcome: saved.outcome }, steps, saved);
|
|
186
|
+
};
|
|
187
|
+
if (!recordingFailed && !signal.aborted) {
|
|
188
|
+
try {
|
|
189
|
+
const batch = await executor.executeBatch({
|
|
190
|
+
calls: model.calls, scope: snapshot({ ...scope, step: steps }), signal,
|
|
191
|
+
async beforeExecute(identity) {
|
|
192
|
+
const call = model.calls[settled.size];
|
|
193
|
+
if (!call || identity.sessionId !== scope.sessionId || identity.turnId !== scope.turnId
|
|
194
|
+
|| identity.step !== steps || identity.toolCallId !== call.id || identity.name !== call.name)
|
|
195
|
+
throw new Error('Executor intent identity does not match the next call');
|
|
196
|
+
if (recordingFailed)
|
|
197
|
+
throw new Error('Turn no longer permits a new tool effect');
|
|
198
|
+
if (signal.aborted)
|
|
199
|
+
return;
|
|
200
|
+
await emit({ type: 'tool_started', step: steps, toolCallId: call.id, name: call.name });
|
|
201
|
+
if (recordingFailed)
|
|
202
|
+
throw new Error('Required intent recording stopped execution');
|
|
203
|
+
// The executor checks cancellation after this barrier. A
|
|
204
|
+
// successful write followed by cancellation is not a failed write.
|
|
205
|
+
},
|
|
206
|
+
async afterExecute(settlement) {
|
|
207
|
+
await acceptSettlement(settlement);
|
|
208
|
+
if (recordingFailed)
|
|
209
|
+
throw new Error('Required settlement recording failed; do not start further effects');
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
if (batch.results.length !== model.calls.length)
|
|
213
|
+
throw new Error('Executor omitted settlements');
|
|
214
|
+
for (const settlement of batch.results) {
|
|
215
|
+
const recorded = settled.get(settlement.identity.toolCallId);
|
|
216
|
+
if (recorded) {
|
|
217
|
+
if (JSON.stringify(recorded) !== JSON.stringify(settlement))
|
|
218
|
+
throw new Error('Executor changed a recorded settlement');
|
|
130
219
|
}
|
|
220
|
+
else
|
|
221
|
+
await acceptSettlement(settlement);
|
|
222
|
+
}
|
|
223
|
+
batchFailed = !!batch.recordingError || (batch.stopped !== null && batch.stopped !== 'cancelled');
|
|
224
|
+
if (batchFailed) {
|
|
225
|
+
const unknown = batch.results.find(r => !r.outcome.ok && r.outcome.error.effect === 'unknown')?.outcome;
|
|
226
|
+
error ??= unknown && !unknown.ok ? unknown.error : {
|
|
227
|
+
code: batch.recordingError?.code ?? batch.stopped,
|
|
228
|
+
message: batch.recordingError?.message ?? `Tool batch stopped: ${batch.stopped}; inspect settlement evidence`,
|
|
229
|
+
};
|
|
131
230
|
}
|
|
132
231
|
}
|
|
133
|
-
|
|
134
|
-
|
|
232
|
+
catch (cause) {
|
|
233
|
+
batchFailed = true;
|
|
234
|
+
error ??= { code: 'tool_protocol', message: cause instanceof Error ? cause.message : String(cause) };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
for (const call of model.calls) {
|
|
238
|
+
usedIds.add(call.id);
|
|
239
|
+
const receipt = settled.get(call.id);
|
|
240
|
+
const outcome = receipt?.outcome ?? (batchFailed
|
|
241
|
+
? failed('tool_protocol', 'Executor did not return a confirmed settlement', 'unknown')
|
|
242
|
+
: failed(signal.aborted ? 'cancelled_before_start' : 'not_started', 'Tool was not started'));
|
|
135
243
|
if (!outcome.ok && outcome.error.effect === 'unknown') {
|
|
136
244
|
uncertain = true;
|
|
137
245
|
error ??= { code: outcome.error.code, message: outcome.error.message };
|
|
138
246
|
}
|
|
139
|
-
|
|
247
|
+
if (!receipt)
|
|
248
|
+
await append({ role: 'tool', toolCallId: call.id, name: call.name, outcome }, steps);
|
|
140
249
|
}
|
|
141
|
-
if (uncertain ||
|
|
250
|
+
if (uncertain || recordingFailed || batchFailed) {
|
|
142
251
|
reason = 'error';
|
|
143
252
|
break;
|
|
144
253
|
}
|
|
@@ -157,17 +266,10 @@ export function createAgent(options) {
|
|
|
157
266
|
message: cause instanceof Error ? cause.message : String(cause) };
|
|
158
267
|
reason = 'error';
|
|
159
268
|
}
|
|
160
|
-
if (
|
|
269
|
+
if (recordingFailed)
|
|
161
270
|
reason = 'error';
|
|
162
|
-
// If the sink rejects the terminal itself, replace that one in-memory
|
|
163
|
-
// terminal with the truthful failure. Never recursively retry the sink.
|
|
164
271
|
await emit({ type: 'turn_ended', reason, ...(error ? { errorCode: error.code } : {}) });
|
|
165
|
-
|
|
166
|
-
reason = 'error';
|
|
167
|
-
const last = events[events.length - 1];
|
|
168
|
-
events[events.length - 1] = snapshot({ ...last, data: { type: 'turn_ended', reason, errorCode: error.code } });
|
|
169
|
-
}
|
|
170
|
-
return snapshot({ ...scope, reason, messages, events, steps, ...(error ? { error } : {}) });
|
|
272
|
+
return snapshot({ ...scope, reason, messages, events, steps, recording, observerErrors, contextReports, ...(error ? { error } : {}) });
|
|
171
273
|
},
|
|
172
274
|
};
|
|
173
275
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createMockProvider, createTextTools } from './index.js';
|
|
5
|
+
import { createMemoryDemoSessions, openCli } from './interaction/index.js';
|
|
6
|
+
// This program owns its disposable files and execution service. The CLI itself
|
|
7
|
+
// only detaches; this owner explicitly cancels and settles turns on process exit.
|
|
8
|
+
let root;
|
|
9
|
+
let sessions;
|
|
10
|
+
let cli;
|
|
11
|
+
let stopping = false;
|
|
12
|
+
const interrupt = () => { stopping = true; cli?.close(); };
|
|
13
|
+
process.on('SIGINT', interrupt);
|
|
14
|
+
process.on('SIGTERM', interrupt);
|
|
15
|
+
try {
|
|
16
|
+
root = await mkdtemp(path.join(tmpdir(), 'independent-agent-cli-'));
|
|
17
|
+
await writeFile(path.join(root, 'input.txt'), 'Disposable mock CLI example.\n');
|
|
18
|
+
let draw = 0;
|
|
19
|
+
sessions = createMemoryDemoSessions({
|
|
20
|
+
provider: createMockProvider({ toolCallProbability: 0.5, random: () => [0.1, 0.2, 0.9][draw++ % 3] }),
|
|
21
|
+
tools: createTextTools({ root }),
|
|
22
|
+
});
|
|
23
|
+
cli = await openCli({ sessions, input: process.stdin, output: process.stdout });
|
|
24
|
+
if (stopping)
|
|
25
|
+
cli.close();
|
|
26
|
+
const end = await cli.done;
|
|
27
|
+
if (end.reason === 'display-error' || end.reason === 'input-error')
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
process.off('SIGINT', interrupt);
|
|
32
|
+
process.off('SIGTERM', interrupt);
|
|
33
|
+
cli?.close();
|
|
34
|
+
await sessions?.close();
|
|
35
|
+
if (root)
|
|
36
|
+
await rm(root, { recursive: true, force: true });
|
|
37
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createTextTools } from './textTools.js';
|
|
2
|
+
import { createSearchTools } from './searchTools.js';
|
|
3
|
+
import { createCommandTool } from './commandTool.js';
|
|
4
|
+
/** Explicit assembly only; each returned Tool can instead be selected or replaced independently. */
|
|
5
|
+
export function createCodingTools(options) {
|
|
6
|
+
return [
|
|
7
|
+
...createTextTools({ ...options.text, root: options.root }),
|
|
8
|
+
...createSearchTools({ ...options.search, root: options.root }),
|
|
9
|
+
...(options.command ? [createCommandTool({ ...options.command, root: options.root })] : []),
|
|
10
|
+
];
|
|
11
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const failure = (code, message, effect = 'none') => ({ ok: false, error: { code, message, effect } });
|
|
5
|
+
const inRoot = (root, cwd) => {
|
|
6
|
+
const relative = path.relative(root, cwd);
|
|
7
|
+
return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
8
|
+
};
|
|
9
|
+
const bounded = (value, maximum, label) => {
|
|
10
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
|
|
11
|
+
throw new Error(`${label} must be an integer in [1, ${maximum}]`);
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Controlled POSIX commands only, not an OS sandbox. Commands can access paths
|
|
17
|
+
* outside cwd, perform external effects, or escape the process group themselves.
|
|
18
|
+
* Callers must authorize the executable/arguments and supply a deliberate env.
|
|
19
|
+
*/
|
|
20
|
+
export function createCommandTool(options) {
|
|
21
|
+
if (process.platform === 'win32')
|
|
22
|
+
throw new Error('Command tool requires POSIX process groups');
|
|
23
|
+
if (!options || typeof options.root !== 'string' || !path.isAbsolute(options.root)) {
|
|
24
|
+
throw new Error('An explicit absolute workspace root is required');
|
|
25
|
+
}
|
|
26
|
+
const root = realpathSync(options.root);
|
|
27
|
+
if (!statSync(root).isDirectory())
|
|
28
|
+
throw new Error('Workspace root must be a directory');
|
|
29
|
+
if (!options.env || typeof options.env !== 'object' || Array.isArray(options.env)
|
|
30
|
+
|| Object.entries(options.env).some(([key, value]) => !key || /[=\0]/.test(key) || typeof value !== 'string' || value.includes('\0'))) {
|
|
31
|
+
throw new Error('An explicit string environment without NUL or invalid keys is required');
|
|
32
|
+
}
|
|
33
|
+
const env = { ...options.env };
|
|
34
|
+
const timeoutMs = bounded(options.timeoutMs ?? 10_000, 60_000, 'timeoutMs');
|
|
35
|
+
const maxOutputBytes = bounded(options.maxOutputBytes ?? 64 * 1024, 64 * 1024, 'maxOutputBytes');
|
|
36
|
+
const killGraceMs = bounded(options.killGraceMs ?? 100, 1000, 'killGraceMs');
|
|
37
|
+
const validate = (args) => {
|
|
38
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)
|
|
39
|
+
|| Object.keys(args).some(key => !['command', 'argv', 'cwd'].includes(key))
|
|
40
|
+
|| typeof args.command !== 'string' || !path.isAbsolute(args.command) || args.command.includes('\0')
|
|
41
|
+
|| typeof args.cwd !== 'string' || !path.isAbsolute(args.cwd) || args.cwd.includes('\0')
|
|
42
|
+
|| !Array.isArray(args.argv) || args.argv.length > 256
|
|
43
|
+
|| args.argv.some(arg => typeof arg !== 'string' || arg.includes('\0'))
|
|
44
|
+
// Evidence echoes argv. Its JSON string is encoded once more in the
|
|
45
|
+
// ToolOutcome: reserve room for 64KiB of worst-case control-byte output
|
|
46
|
+
// (7x after both JSON encodings) within the public 512KiB message bound.
|
|
47
|
+
|| Buffer.byteLength(JSON.stringify(args)) > 16 * 1024) {
|
|
48
|
+
return { code: 'invalid_arguments', message: 'Expected absolute command/cwd and bounded string argv only', effect: 'none' };
|
|
49
|
+
}
|
|
50
|
+
if (!inRoot(root, path.resolve(args.cwd))) {
|
|
51
|
+
return { code: 'path_out_of_scope', message: 'cwd must be within the explicit workspace root', effect: 'none' };
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
definition: {
|
|
57
|
+
name: 'command',
|
|
58
|
+
description: 'Execute an explicitly authorized POSIX executable and argv in a controlled workspace; no implicit shell or OS sandbox',
|
|
59
|
+
inputSchema: {
|
|
60
|
+
type: 'object', properties: {
|
|
61
|
+
command: { type: 'string', description: 'Absolute executable path' },
|
|
62
|
+
argv: { type: 'array', items: { type: 'string' }, maxItems: 256 },
|
|
63
|
+
cwd: { type: 'string', description: 'Absolute working directory within configured root' },
|
|
64
|
+
}, required: ['command', 'argv', 'cwd'], additionalProperties: false,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
validate,
|
|
68
|
+
async execute(args, _scope, signal) {
|
|
69
|
+
const invalid = validate(args);
|
|
70
|
+
if (invalid)
|
|
71
|
+
return { ok: false, error: invalid };
|
|
72
|
+
if (signal.aborted)
|
|
73
|
+
return failure('cancelled_before_start', 'Command was not started');
|
|
74
|
+
const input = args;
|
|
75
|
+
// Copy caller-owned data before spawning; direct execute calls receive the
|
|
76
|
+
// same checks as calls through the Agent loop.
|
|
77
|
+
const command = input.command;
|
|
78
|
+
const argv = [...input.argv];
|
|
79
|
+
let cwd;
|
|
80
|
+
try {
|
|
81
|
+
cwd = realpathSync(input.cwd);
|
|
82
|
+
if (!inRoot(root, cwd))
|
|
83
|
+
return failure('path_out_of_scope', 'Resolved cwd is outside workspace root');
|
|
84
|
+
if (!statSync(cwd).isDirectory())
|
|
85
|
+
return failure('invalid_cwd', 'cwd is not a directory');
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return failure('invalid_cwd', 'cwd cannot be resolved as an accessible directory');
|
|
89
|
+
}
|
|
90
|
+
return new Promise(resolve => {
|
|
91
|
+
let child;
|
|
92
|
+
try {
|
|
93
|
+
child = spawn(command, argv, { cwd, env, shell: false, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
resolve(failure('spawn_failed', `Command could not be spawned (${error.code ?? 'unknown'})`));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
let settled = false;
|
|
100
|
+
let started = false;
|
|
101
|
+
let directChildExited = false;
|
|
102
|
+
let exitCode = null;
|
|
103
|
+
let exitSignal = null;
|
|
104
|
+
let reason;
|
|
105
|
+
let processErrorCode = null;
|
|
106
|
+
let captured = 0;
|
|
107
|
+
let outputTruncated = false;
|
|
108
|
+
const stdout = [];
|
|
109
|
+
const stderr = [];
|
|
110
|
+
const signalErrors = [];
|
|
111
|
+
let grace;
|
|
112
|
+
let settle;
|
|
113
|
+
const groupState = () => {
|
|
114
|
+
if (!child.pid)
|
|
115
|
+
return 'absent';
|
|
116
|
+
try {
|
|
117
|
+
process.kill(-child.pid, 0);
|
|
118
|
+
return 'present';
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
return error.code === 'ESRCH' ? 'absent' : 'unknown';
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const signalGroup = (kind) => {
|
|
125
|
+
if (!child.pid)
|
|
126
|
+
return;
|
|
127
|
+
try {
|
|
128
|
+
process.kill(-child.pid, kind);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
const code = error.code;
|
|
132
|
+
if (code !== 'ESRCH')
|
|
133
|
+
signalErrors.push(`${kind}:${code ?? 'unknown'}`);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const finish = () => {
|
|
137
|
+
if (settled)
|
|
138
|
+
return;
|
|
139
|
+
settled = true;
|
|
140
|
+
clearTimeout(deadline);
|
|
141
|
+
clearTimeout(grace);
|
|
142
|
+
clearTimeout(settle);
|
|
143
|
+
signal.removeEventListener('abort', abort);
|
|
144
|
+
child.stdout.destroy();
|
|
145
|
+
child.stderr.destroy();
|
|
146
|
+
// A failure to observe termination must not keep the caller waiting
|
|
147
|
+
// forever. Keep the pid and uncertainty in the returned evidence.
|
|
148
|
+
child.unref();
|
|
149
|
+
const evidence = {
|
|
150
|
+
command, argv, cwd, pid: child.pid ?? null, exitCode, signal: exitSignal,
|
|
151
|
+
stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'),
|
|
152
|
+
capturedBytes: captured, outputTruncated, directChildExited,
|
|
153
|
+
processGroup: groupState(), signalErrors, processErrorCode,
|
|
154
|
+
descendantsMayHaveEscaped: true,
|
|
155
|
+
};
|
|
156
|
+
resolve(reason
|
|
157
|
+
? failure(reason, JSON.stringify(evidence), started ? 'unknown' : 'none')
|
|
158
|
+
: { ok: true, content: JSON.stringify(evidence) });
|
|
159
|
+
};
|
|
160
|
+
const stop = (code) => {
|
|
161
|
+
if (settled || reason)
|
|
162
|
+
return;
|
|
163
|
+
reason = code;
|
|
164
|
+
signalGroup('SIGTERM');
|
|
165
|
+
grace = setTimeout(() => {
|
|
166
|
+
signalGroup('SIGKILL');
|
|
167
|
+
// Bound observation after escalation; never infer quiescence from a
|
|
168
|
+
// successful signal syscall, or wait forever on inherited pipes.
|
|
169
|
+
settle = setTimeout(finish, 100);
|
|
170
|
+
}, killGraceMs);
|
|
171
|
+
};
|
|
172
|
+
const abort = () => stop('cancelled');
|
|
173
|
+
const deadline = setTimeout(() => stop('timeout'), timeoutMs);
|
|
174
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
175
|
+
const capture = (target, chunk) => {
|
|
176
|
+
const available = maxOutputBytes - captured;
|
|
177
|
+
if (available > 0) {
|
|
178
|
+
const kept = chunk.subarray(0, available);
|
|
179
|
+
target.push(Buffer.from(kept));
|
|
180
|
+
captured += kept.length;
|
|
181
|
+
}
|
|
182
|
+
if (chunk.length > available) {
|
|
183
|
+
outputTruncated = true;
|
|
184
|
+
stop('output_limit');
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
child.stdout.on('data', (chunk) => capture(stdout, chunk));
|
|
188
|
+
child.stderr.on('data', (chunk) => capture(stderr, chunk));
|
|
189
|
+
child.once('spawn', () => { started = true; });
|
|
190
|
+
child.once('error', error => {
|
|
191
|
+
processErrorCode = error.code ?? 'unknown';
|
|
192
|
+
if (started)
|
|
193
|
+
stop('process_error');
|
|
194
|
+
else {
|
|
195
|
+
reason = 'spawn_failed';
|
|
196
|
+
finish();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
child.once('exit', (code, receivedSignal) => {
|
|
200
|
+
directChildExited = true;
|
|
201
|
+
exitCode = code;
|
|
202
|
+
exitSignal = receivedSignal;
|
|
203
|
+
if (!reason && groupState() !== 'absent')
|
|
204
|
+
stop('background_processes');
|
|
205
|
+
});
|
|
206
|
+
child.once('close', () => {
|
|
207
|
+
if (!reason)
|
|
208
|
+
finish();
|
|
209
|
+
});
|
|
210
|
+
if (signal.aborted)
|
|
211
|
+
abort();
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|