@zq-silk/yui 1.1.2 → 2.1.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/ARCHITECTURE.md +2 -1
- package/ARCHITECTURE.zh-CN.md +2 -1
- package/README.md +2 -0
- package/dist/cli/commandCatalog.js +344 -324
- package/dist/cli/commandDiscovery.js +69 -0
- package/dist/cli/completion.js +22 -227
- package/dist/cli/dynamicCompletion.js +12 -6
- package/dist/cli/invocationAuthority.js +2 -2
- package/dist/cli/invocationRouter.js +18 -7
- package/dist/cli/managedDiagnostics.js +2 -1
- package/dist/cli/updatePorts.js +0 -5
- package/dist/cli.js +184 -130
- package/dist/commands/capabilityCommands.js +5 -5
- package/dist/commands/controllerCommands.js +2 -1
- package/dist/commands/globalRoleCommands.js +86 -44
- package/dist/commands/grantCommands.js +48 -8
- package/dist/commands/taskCommands.js +157 -196
- package/dist/commands/taskContextCommand.js +29 -6
- package/dist/commands/taskFactCommands.js +10 -69
- package/dist/commands/taskInputCommands.js +26 -35
- package/dist/commands/taskPublicationCommands.js +2 -35
- package/dist/commands/taskPublicationVerifyCommand.js +1 -2
- package/dist/commands/taskUpstreamCommands.js +2 -1
- package/dist/context/runContextPack.js +8 -29
- package/dist/context/sessionBootstrapManifest.js +5 -101
- package/dist/context/taskContext.js +104 -41
- package/dist/controller/clientRuntime.js +16 -8
- package/dist/controller/fileSchedulerStoreAdapter.js +24 -0
- package/dist/controller/runtime.js +6 -0
- package/dist/core/controllerClient.js +46 -21
- package/dist/core/protocol.js +12 -7
- package/dist/doctor/doctor.js +4 -3
- package/dist/errors/cliError.js +4 -4
- package/dist/errors/cliFailure.js +213 -0
- package/dist/executor/fileRoleLaunchPlanner.js +1 -4
- package/dist/grant/capabilityGrant.js +13 -0
- package/dist/grant/taskAuthorization.js +127 -0
- package/dist/kernel/accessAssessment.js +1 -0
- package/dist/kernel/builtinCapabilities.js +88 -9
- package/dist/kernel/capabilityRegistry.js +64 -19
- package/dist/kernel/kernelPorts.js +2 -2
- package/dist/nativeAgent/agent.js +173 -0
- package/dist/nativeAgent/contracts.js +1 -0
- package/dist/nativeAgent/demo.js +29 -0
- package/dist/nativeAgent/index.js +3 -0
- package/dist/nativeAgent/mockProvider.js +43 -0
- package/dist/nativeAgent/textTools.js +156 -0
- package/dist/nativeAgent/validation.js +95 -0
- package/dist/output/boundedRead.js +118 -0
- package/dist/plugins/pluginService.js +98 -8
- package/dist/release/releaseWorkflowEngine.js +8 -1
- package/dist/resources/projectResourceService.js +23 -1
- package/dist/runtime/managedCaller.js +2 -2
- package/dist/runtime/managedIdentity.js +6 -0
- package/dist/runtime/runtimeCoherence.js +10 -8
- package/dist/storage/sqliteSchema.js +10 -0
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/taskStore.js +3 -0
- package/dist/surface/surfaceContributions.js +3 -0
- package/dist/task/leaderArchive.js +111 -0
- package/dist/task/leaderArchiveAuthority.js +24 -0
- package/dist/task/taskAuthority.js +5 -10
- package/dist/tmux/commandExecutor.js +7 -5
- package/dist/web/assets/assetManifest.js +38 -22
- package/dist/web/assets/client/api.js +115 -0
- package/dist/web/assets/client/app.js +555 -863
- package/dist/web/assets/client/components.js +323 -897
- package/dist/web/assets/client/detail.js +333 -0
- package/dist/web/assets/client/dock.js +225 -0
- package/dist/web/assets/client/dom.js +55 -3
- package/dist/web/assets/client/evidence.js +172 -0
- package/dist/web/assets/client/format.js +25 -14
- package/dist/web/assets/client/forms.js +230 -0
- package/dist/web/assets/client/i18n.js +1020 -791
- package/dist/web/assets/client/overview.js +115 -0
- package/dist/web/assets/client/records.js +63 -0
- package/dist/web/assets/client/sections.js +313 -0
- package/dist/web/assets/client/sidebar.js +117 -0
- package/dist/web/assets/client/theme.js +61 -22
- package/dist/web/assets/icons.js +39 -0
- package/dist/web/assets/shell.js +124 -118
- package/dist/web/assets/styles/base.js +41 -0
- package/dist/web/assets/styles/components.js +167 -0
- package/dist/web/assets/styles/layout.js +49 -77
- package/dist/web/assets/styles/markdown.js +16 -24
- package/dist/web/assets/styles/responsive.js +33 -44
- package/dist/web/assets/styles/tokens.js +65 -89
- package/dist/web/assets/styles/views.js +289 -0
- package/dist/web/webServer.js +2 -1
- package/docs/architecture/README.md +1 -0
- package/docs/architecture/README.zh-CN.md +1 -0
- package/docs/cli-information-contract.md +107 -0
- package/docs/cli-information-contract.zh-CN.md +81 -0
- package/docs/managed-turn-and-session-runtime.md +9 -0
- package/docs/managed-turn-and-session-runtime.zh-CN.md +6 -0
- package/docs/plugin-sdk.md +7 -3
- package/docs/plugin-sdk.zh-CN.md +5 -2
- package/docs/release-workflow.md +42 -9
- package/docs/release-workflow.zh-CN.md +30 -7
- package/docs/storage-baseline.md +5 -0
- package/docs/testing/verification-levels.md +2 -2
- package/i18n/README.zh-CN.md +2 -0
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +8 -1
- package/skills/yui-leader/references/authorization.md +64 -0
- package/skills/yui-leader/references/execution.md +18 -4
- package/skills/yui-leader/references/task-plugins.md +4 -2
- package/skills/yui-operator/SKILL.md +9 -2
- package/skills/yui-runtime/SKILL.md +77 -9
- package/skills/yui-runtime/references/publication.md +3 -1
- package/dist/web/assets/client/taskSummary.js +0 -350
- package/dist/web/assets/client/taskSurface.js +0 -616
- package/dist/web/assets/client/view.js +0 -608
- package/dist/web/assets/styles/cards.js +0 -247
- package/dist/web/assets/styles/widgets.js +0 -168
|
@@ -17,11 +17,13 @@ const reserved = new Set(["yui", "task", "context", "config", "job", "resource",
|
|
|
17
17
|
export class CapabilityRegistry {
|
|
18
18
|
host;
|
|
19
19
|
authorize;
|
|
20
|
+
assess;
|
|
20
21
|
#descriptors = [];
|
|
21
22
|
#coreProviders = new Set();
|
|
22
|
-
constructor(host, authorize, builtins = []) {
|
|
23
|
+
constructor(host, authorize, builtins = [], assess) {
|
|
23
24
|
this.host = host;
|
|
24
25
|
this.authorize = authorize;
|
|
26
|
+
this.assess = assess;
|
|
25
27
|
this.#publish(builtins, true);
|
|
26
28
|
builtins.forEach((entry) => this.#coreProviders.add(entry.provider.id));
|
|
27
29
|
}
|
|
@@ -38,7 +40,7 @@ export class CapabilityRegistry {
|
|
|
38
40
|
if (!prepared.length)
|
|
39
41
|
throw new Error("Plugin must contribute capabilities.");
|
|
40
42
|
const available = [
|
|
41
|
-
...this
|
|
43
|
+
...this.#authorized(context).filter((entry) => entry.provider.id !== prepared[0].provider.id),
|
|
42
44
|
...prepared
|
|
43
45
|
];
|
|
44
46
|
const check = (entry, path) => {
|
|
@@ -47,7 +49,8 @@ export class CapabilityRegistry {
|
|
|
47
49
|
throw new Error("Required capability dependency cycle.");
|
|
48
50
|
for (const dependency of entry.required ?? []) {
|
|
49
51
|
const matches = available.filter((candidate) => candidate.name === dependency.name
|
|
50
|
-
&& candidate.contractVersion === dependency.contractVersion && candidate.unavailable === undefined
|
|
52
|
+
&& candidate.contractVersion === dependency.contractVersion && candidate.unavailable === undefined
|
|
53
|
+
&& (prepared.includes(candidate) || this.host.isAvailable(candidate.provider)));
|
|
51
54
|
if (matches.length !== 1)
|
|
52
55
|
throw new Error(`Required capability is missing or ambiguous: ${dependency.name}.`);
|
|
53
56
|
check(matches[0], new Set([...path, entry]));
|
|
@@ -58,9 +61,9 @@ export class CapabilityRegistry {
|
|
|
58
61
|
disable(providerId) {
|
|
59
62
|
this.#descriptors = this.#descriptors.filter((entry) => entry.provider.id !== providerId);
|
|
60
63
|
}
|
|
61
|
-
|
|
64
|
+
#authorized(context) {
|
|
62
65
|
const visibility = this.authorize(context);
|
|
63
|
-
|
|
66
|
+
return this.#descriptors.filter((entry) => visible(entry.scope, visibility))
|
|
64
67
|
.filter((entry) => {
|
|
65
68
|
try {
|
|
66
69
|
this.authorize(context, entry);
|
|
@@ -70,25 +73,28 @@ export class CapabilityRegistry {
|
|
|
70
73
|
return false;
|
|
71
74
|
}
|
|
72
75
|
});
|
|
73
|
-
return authorized.filter((entry) => `${entry.name} ${entry.summary}`.toLowerCase().includes(query.toLowerCase()))
|
|
74
|
-
.map((entry) => {
|
|
75
|
-
const unavailable = this.#unavailable(entry, authorized, new Set());
|
|
76
|
-
return unavailable === undefined ? entry : Object.freeze({ ...entry, unavailable });
|
|
77
|
-
});
|
|
78
76
|
}
|
|
79
|
-
|
|
77
|
+
search(context, query = "") {
|
|
78
|
+
// Descriptor discovery must not acquire or inspect runtime Providers. These
|
|
79
|
+
// are templates: without input, no exact-target authorization is asserted.
|
|
80
|
+
return this.#authorized(context).filter((entry) => `${entry.name} ${entry.summary}`.toLowerCase().includes(query.toLowerCase()))
|
|
81
|
+
.map(entry => {
|
|
82
|
+
const access = this.assess?.(context, entry, undefined);
|
|
83
|
+
return access ? { ...entry, access } : entry;
|
|
84
|
+
}).filter(entry => !("access" in entry) || entry.access?.state !== "hidden");
|
|
85
|
+
}
|
|
86
|
+
#select(context, request, execution) {
|
|
80
87
|
try {
|
|
81
|
-
const
|
|
88
|
+
const authorized = this.#authorized(context);
|
|
89
|
+
const candidates = authorized.filter((entry) => entry.name === request.name
|
|
82
90
|
&& (request.contractVersion === undefined || request.contractVersion === entry.contractVersion)
|
|
83
91
|
&& (request.providerId === undefined || request.providerId === entry.provider.id));
|
|
84
92
|
if (!candidates.length)
|
|
85
93
|
return result("unavailable", "No authorized compatible Provider is available.");
|
|
86
|
-
const available = candidates.filter(
|
|
87
|
-
if (!available.length)
|
|
88
|
-
return {
|
|
89
|
-
|
|
90
|
-
candidates
|
|
91
|
-
};
|
|
94
|
+
const available = execution ? candidates.filter(entry => this.#unavailable(entry, authorized, new Set()) === undefined) : candidates;
|
|
95
|
+
if (!available.length) {
|
|
96
|
+
return { ...result("unavailable", candidates.map(entry => `${entry.provider.id}: ${this.#unavailable(entry, authorized, new Set())}`).join("; ")), candidates };
|
|
97
|
+
}
|
|
92
98
|
if (available.length > 1)
|
|
93
99
|
return { ...result("ambiguous", "Select an explicit Provider and contract version."), candidates };
|
|
94
100
|
const selected = available[0];
|
|
@@ -101,6 +107,32 @@ export class CapabilityRegistry {
|
|
|
101
107
|
return result("denied", "Current call authority is unavailable.");
|
|
102
108
|
}
|
|
103
109
|
}
|
|
110
|
+
describe(context, request) {
|
|
111
|
+
const selected = this.#select(context, request, false);
|
|
112
|
+
if (selected.kind !== "value")
|
|
113
|
+
return selected;
|
|
114
|
+
const descriptor = selected.value;
|
|
115
|
+
if (Object.hasOwn(request, "input")) {
|
|
116
|
+
const error = capabilitySchemaError(descriptor.inputSchema, request.input);
|
|
117
|
+
if (error)
|
|
118
|
+
return result("invalid", error);
|
|
119
|
+
try {
|
|
120
|
+
this.authorize(context, descriptor, request.input);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return result("denied", "Current call authority is unavailable.");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const access = this.assess?.(context, descriptor, request.input);
|
|
128
|
+
if (access?.state === "hidden")
|
|
129
|
+
return result("unavailable", "No authorized compatible Provider is available.");
|
|
130
|
+
return access ? { ...selected, value: { ...descriptor, access } } : selected;
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
return result("failed", error instanceof Error ? error.message : "Authorization assessment failed.");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
104
136
|
call(context, request) {
|
|
105
137
|
return this.#call(context, request, "external-operation");
|
|
106
138
|
}
|
|
@@ -112,7 +144,11 @@ export class CapabilityRegistry {
|
|
|
112
144
|
catch {
|
|
113
145
|
return result("invalid", "Capability request must be cloneable data.");
|
|
114
146
|
}
|
|
115
|
-
|
|
147
|
+
// Execution selects independently of discovery assessment and rechecks the
|
|
148
|
+
// exact input below. A requestable descriptor never grants admission.
|
|
149
|
+
const resolved = this.#select(context, {
|
|
150
|
+
name: request.name, contractVersion: request.contractVersion, providerId: request.providerId
|
|
151
|
+
}, true);
|
|
116
152
|
if (resolved.kind !== "value")
|
|
117
153
|
return resolved;
|
|
118
154
|
const descriptor = resolved.value;
|
|
@@ -135,6 +171,15 @@ export class CapabilityRegistry {
|
|
|
135
171
|
catch {
|
|
136
172
|
return { ...result("denied", "Current call authority was revoked."), ...origin };
|
|
137
173
|
}
|
|
174
|
+
try {
|
|
175
|
+
const access = this.assess?.(context, descriptor, request.input);
|
|
176
|
+
if (access && access.state !== "authorized") {
|
|
177
|
+
return { ...result("denied", "Exact target authorization is required."), ...origin };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
return { ...result("failed", error instanceof Error ? error.message : "Authorization assessment failed."), ...origin };
|
|
182
|
+
}
|
|
138
183
|
const operations = [];
|
|
139
184
|
let effect = "none";
|
|
140
185
|
const observe = (operation) => {
|
|
@@ -6,14 +6,14 @@ import { InstanceHost } from "./instanceHost.js";
|
|
|
6
6
|
* another Controller, or provide arbitrary persistence to plugin code.
|
|
7
7
|
* T02 registers contributions on this Host and wraps this same Job control.
|
|
8
8
|
*/
|
|
9
|
-
export function createKernelPorts(store, runner, signal = () => undefined, contextProviders = []) {
|
|
9
|
+
export function createKernelPorts(store, runner, signal = () => undefined, contextProviders = [], archiveTask) {
|
|
10
10
|
const host = new InstanceHost();
|
|
11
11
|
const runnerImplementation = host.attach(JOB_RUNNER_IMPLEMENTATION, runner);
|
|
12
12
|
const runnerHandle = host.acquire(runnerImplementation);
|
|
13
13
|
const jobImplementation = host.attach({ id: "yui:job-control", generation: "1" }, createDurableJobControl(store));
|
|
14
14
|
// The Controller is a long-lived consumer of this exact implementation.
|
|
15
15
|
const jobHandle = host.acquire(jobImplementation);
|
|
16
|
-
const capabilities = createBuiltinCapabilities(host, store, jobHandle.value, signal, contextProviders);
|
|
16
|
+
const capabilities = createBuiltinCapabilities(host, store, jobHandle.value, signal, contextProviders, archiveTask);
|
|
17
17
|
return {
|
|
18
18
|
host,
|
|
19
19
|
capabilities,
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { AgentFault, history, limits, response, size, snapshot, validOutcome } from './validation.js';
|
|
2
|
+
const failed = (code, message, effect = 'none') => ({ ok: false, error: { code, message, effect } });
|
|
3
|
+
export function createAgent(options) {
|
|
4
|
+
const tools = new Map(options.tools.map(t => [t.definition.name, t]));
|
|
5
|
+
if (tools.size !== options.tools.length || [...tools.keys()].some(name => !name.trim())) {
|
|
6
|
+
throw new Error('Tool names must be unique and nonempty');
|
|
7
|
+
}
|
|
8
|
+
const definitions = snapshot(options.tools.map(t => t.definition));
|
|
9
|
+
return {
|
|
10
|
+
async runTurn(input) {
|
|
11
|
+
const scope = { sessionId: input.sessionId, turnId: input.turnId };
|
|
12
|
+
const signal = input.signal ?? new AbortController().signal;
|
|
13
|
+
const messages = [];
|
|
14
|
+
const events = [];
|
|
15
|
+
let steps = 0;
|
|
16
|
+
let reason = 'error';
|
|
17
|
+
let error;
|
|
18
|
+
let sinkFailed = false;
|
|
19
|
+
const emit = async (data) => {
|
|
20
|
+
const event = snapshot({ ...scope, seq: events.length + 1, data });
|
|
21
|
+
events.push(event);
|
|
22
|
+
if (options.onEvent && !sinkFailed) {
|
|
23
|
+
try {
|
|
24
|
+
await options.onEvent(event);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
sinkFailed = true;
|
|
28
|
+
error = { code: 'event_sink_failed', message: 'Event sink rejected a fact; inspect in-memory evidence' };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const append = async (message, step) => {
|
|
33
|
+
const saved = snapshot(message);
|
|
34
|
+
messages.push(saved);
|
|
35
|
+
await emit({ type: 'message_appended', step, message: saved });
|
|
36
|
+
};
|
|
37
|
+
await emit({ type: 'turn_started' });
|
|
38
|
+
try {
|
|
39
|
+
if (!scope.sessionId?.trim() || !scope.turnId?.trim()
|
|
40
|
+
|| !Number.isSafeInteger(input.maxSteps) || input.maxSteps < 1 || typeof input.input !== 'string'
|
|
41
|
+
|| size({ role: 'user', content: input.input }) > limits.messageBytes) {
|
|
42
|
+
throw new AgentFault('invalid_input', 'Nonempty identities, bounded input and positive integer maxSteps required');
|
|
43
|
+
}
|
|
44
|
+
const prior = history(input.history ?? []);
|
|
45
|
+
const usedIds = new Set(prior.flatMap(m => m.role === 'assistant' ? m.toolCalls.map(c => c.id) : []));
|
|
46
|
+
await append({ role: 'user', content: input.input });
|
|
47
|
+
while (!sinkFailed) {
|
|
48
|
+
if (signal.aborted) {
|
|
49
|
+
reason = 'cancelled';
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
if (steps >= input.maxSteps) {
|
|
53
|
+
reason = 'budget_exhausted';
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
const all = [...prior, ...messages];
|
|
57
|
+
if (size(all) > limits.historyBytes)
|
|
58
|
+
throw new AgentFault('context_limit', 'History exceeds context byte limit');
|
|
59
|
+
steps++;
|
|
60
|
+
await emit({ type: 'step_started', step: steps });
|
|
61
|
+
try {
|
|
62
|
+
if (sinkFailed)
|
|
63
|
+
break;
|
|
64
|
+
if (signal.aborted) {
|
|
65
|
+
reason = 'cancelled';
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
const request = snapshot({ ...scope, step: steps, messages: all, tools: definitions });
|
|
69
|
+
let raw;
|
|
70
|
+
try {
|
|
71
|
+
raw = await options.provider.complete(request, signal);
|
|
72
|
+
}
|
|
73
|
+
catch (cause) {
|
|
74
|
+
if (signal.aborted) {
|
|
75
|
+
reason = 'cancelled';
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
throw new AgentFault('provider_error', cause instanceof Error ? cause.message : String(cause));
|
|
79
|
+
}
|
|
80
|
+
if (signal.aborted) {
|
|
81
|
+
reason = 'cancelled';
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
const model = response(raw, usedIds);
|
|
85
|
+
if (model.kind === 'final') {
|
|
86
|
+
await append({ role: 'assistant', content: model.content, toolCalls: [] }, steps);
|
|
87
|
+
reason = signal.aborted ? 'cancelled' : 'completed';
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
await append({ role: 'assistant', content: model.content, toolCalls: model.calls }, steps);
|
|
91
|
+
let uncertain = false;
|
|
92
|
+
for (const call of model.calls) {
|
|
93
|
+
usedIds.add(call.id);
|
|
94
|
+
let outcome;
|
|
95
|
+
if (sinkFailed || uncertain || signal.aborted) {
|
|
96
|
+
outcome = failed(signal.aborted ? 'cancelled_before_start' : 'not_started', 'Tool was not started');
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
const tool = tools.get(call.name);
|
|
100
|
+
if (!tool)
|
|
101
|
+
outcome = failed('unknown_tool', `Unknown tool: ${call.name}`);
|
|
102
|
+
else {
|
|
103
|
+
// Validation has no effects. Unexpected execution failures conservatively
|
|
104
|
+
// preserve uncertainty and stop the batch; never retry a write.
|
|
105
|
+
try {
|
|
106
|
+
const invalid = tool.validate(call.arguments);
|
|
107
|
+
if (invalid) {
|
|
108
|
+
outcome = { ok: false, error: invalid };
|
|
109
|
+
if (!validOutcome(outcome))
|
|
110
|
+
outcome = failed('tool_protocol', 'Invalid tool validation result');
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
await emit({ type: 'tool_started', step: steps, toolCallId: call.id, name: call.name });
|
|
114
|
+
if (sinkFailed || signal.aborted)
|
|
115
|
+
outcome = failed('not_started', 'Tool was not started');
|
|
116
|
+
else {
|
|
117
|
+
try {
|
|
118
|
+
outcome = await tool.execute(call.arguments, { ...scope, step: steps, toolCallId: call.id }, signal);
|
|
119
|
+
if (!validOutcome(outcome))
|
|
120
|
+
outcome = failed('tool_protocol', 'Invalid tool result', 'unknown');
|
|
121
|
+
}
|
|
122
|
+
catch (cause) {
|
|
123
|
+
outcome = failed('tool_exception', cause instanceof Error ? cause.message : String(cause), 'unknown');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
outcome = failed('tool_validation', 'Tool validation failed');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!validOutcome(outcome))
|
|
134
|
+
outcome = failed('tool_protocol', 'Invalid tool failure result', 'unknown');
|
|
135
|
+
if (!outcome.ok && outcome.error.effect === 'unknown') {
|
|
136
|
+
uncertain = true;
|
|
137
|
+
error ??= { code: outcome.error.code, message: outcome.error.message };
|
|
138
|
+
}
|
|
139
|
+
await append({ role: 'tool', toolCallId: call.id, name: call.name, outcome }, steps);
|
|
140
|
+
}
|
|
141
|
+
if (uncertain || sinkFailed) {
|
|
142
|
+
reason = 'error';
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
if (signal.aborted) {
|
|
146
|
+
reason = 'cancelled';
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
await emit({ type: 'step_ended', step: steps });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch (cause) {
|
|
156
|
+
error ??= { code: cause instanceof AgentFault ? cause.code : 'internal_error',
|
|
157
|
+
message: cause instanceof Error ? cause.message : String(cause) };
|
|
158
|
+
reason = 'error';
|
|
159
|
+
}
|
|
160
|
+
if (sinkFailed)
|
|
161
|
+
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
|
+
await emit({ type: 'turn_ended', reason, ...(error ? { errorCode: error.code } : {}) });
|
|
165
|
+
if (sinkFailed) {
|
|
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 } : {}) });
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createAgent, createMockProvider, createTextTools } from './index.js';
|
|
6
|
+
const root = await mkdtemp(path.join(tmpdir(), 'independent-agent-demo-'));
|
|
7
|
+
try {
|
|
8
|
+
const text = 'An independent Agent copied this text through tool results.\n';
|
|
9
|
+
await writeFile(path.join(root, 'input.txt'), text);
|
|
10
|
+
const samples = [0.1, 0.2, 0.9];
|
|
11
|
+
const agent = createAgent({
|
|
12
|
+
tools: createTextTools({ root }),
|
|
13
|
+
provider: createMockProvider({ toolCallProbability: 0.5, random: () => {
|
|
14
|
+
const sample = samples.shift();
|
|
15
|
+
if (sample === undefined)
|
|
16
|
+
throw new Error('Demo random sequence exhausted');
|
|
17
|
+
return sample;
|
|
18
|
+
} }),
|
|
19
|
+
});
|
|
20
|
+
const result = await agent.runTurn({
|
|
21
|
+
sessionId: 'demo-session', turnId: 'demo-turn', input: 'Copy input.txt to output.txt', maxSteps: 3,
|
|
22
|
+
});
|
|
23
|
+
assert.equal(result.reason, 'completed');
|
|
24
|
+
assert.equal(await readFile(path.join(root, 'output.txt'), 'utf8'), text);
|
|
25
|
+
console.log(JSON.stringify({ ...result, fileVerified: true }, null, 2));
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
await rm(root, { recursive: true, force: true });
|
|
29
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** A deliberately small read/write mock, not a semantic planner. */
|
|
2
|
+
export function createMockProvider(options) {
|
|
3
|
+
const { toolCallProbability: p, random = Math.random, readPath = 'input.txt', writePath = 'output.txt' } = options;
|
|
4
|
+
if (!Number.isFinite(p) || p < 0 || p > 1)
|
|
5
|
+
throw new Error('toolCallProbability must be in [0, 1]');
|
|
6
|
+
const latestRead = (request) => {
|
|
7
|
+
for (const m of [...request.messages].reverse()) {
|
|
8
|
+
if (m.role === 'tool' && m.name === 'read' && m.outcome.ok) {
|
|
9
|
+
try {
|
|
10
|
+
const data = JSON.parse(m.outcome.content);
|
|
11
|
+
if (data && typeof data === 'object' && 'text' in data && typeof data.text === 'string')
|
|
12
|
+
return data.text;
|
|
13
|
+
}
|
|
14
|
+
catch { /* Other read implementations need not use the text-tools payload. */ }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
async complete(request, signal) {
|
|
21
|
+
signal.throwIfAborted();
|
|
22
|
+
const u = random();
|
|
23
|
+
if (!Number.isFinite(u) || u < 0 || u >= 1)
|
|
24
|
+
throw new Error('random must return a number in [0, 1)');
|
|
25
|
+
if (u >= p) {
|
|
26
|
+
const outcomes = request.messages.filter(m => m.role === 'tool');
|
|
27
|
+
const success = outcomes.filter(m => m.role === 'tool' && m.outcome.ok).length;
|
|
28
|
+
return { kind: 'final', content: `Observed ${success} successful and ${outcomes.length - success} failed tool results.` };
|
|
29
|
+
}
|
|
30
|
+
const content = latestRead(request);
|
|
31
|
+
const write = request.step % 2 === 0 && content !== undefined;
|
|
32
|
+
const used = new Set(request.messages.flatMap(m => m.role === 'assistant' ? m.toolCalls.map(c => c.id) : []));
|
|
33
|
+
let suffix = 1;
|
|
34
|
+
while (used.has(`mock-${suffix}`))
|
|
35
|
+
suffix++;
|
|
36
|
+
const call = { id: `mock-${suffix}`, name: write ? 'write' : 'read',
|
|
37
|
+
arguments: write ? { path: writePath, content } : { path: readPath } };
|
|
38
|
+
if (!request.tools.some(t => t.name === call.name))
|
|
39
|
+
throw new Error(`Mock requires the ${call.name} tool`);
|
|
40
|
+
return { kind: 'tool_calls', content: '', calls: [call] };
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { constants, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { lstat, open, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
class FileFault extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(code, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const fail = (code, message) => ({ code, message, effect: 'none' });
|
|
13
|
+
/** Controlled local directories only. Path checks are NOT an adversarial sandbox. */
|
|
14
|
+
export function createTextTools({ root, maxBytes = 64 * 1024 }) {
|
|
15
|
+
if (!path.isAbsolute(root))
|
|
16
|
+
throw new Error('An explicit absolute workspace root is required');
|
|
17
|
+
const workspace = realpathSync(root);
|
|
18
|
+
if (!statSync(workspace).isDirectory())
|
|
19
|
+
throw new Error('Workspace must be a directory');
|
|
20
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 64 * 1024) {
|
|
21
|
+
throw new Error('maxBytes must be an integer in [1, 65536]');
|
|
22
|
+
}
|
|
23
|
+
const validate = (args, write) => {
|
|
24
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)
|
|
25
|
+
|| typeof args.path !== 'string' || !args.path.trim()
|
|
26
|
+
|| Object.keys(args).some(k => k !== 'path' && !(write && k === 'content'))
|
|
27
|
+
|| (write && typeof args.content !== 'string'))
|
|
28
|
+
return fail('invalid_arguments', 'Expected path and, for write, content');
|
|
29
|
+
if (args.path.includes('\0') || path.isAbsolute(args.path) || args.path.split(/[\\/]/).includes('..')) {
|
|
30
|
+
return fail('path_out_of_scope', 'Expected a relative path without parent traversal');
|
|
31
|
+
}
|
|
32
|
+
if (write && Buffer.byteLength(args.content) > maxBytes)
|
|
33
|
+
return fail('too_large', 'Text exceeds byte limit');
|
|
34
|
+
return null;
|
|
35
|
+
};
|
|
36
|
+
const check = async (relative, allowMissing) => {
|
|
37
|
+
const target = path.resolve(workspace, relative);
|
|
38
|
+
const rel = path.relative(workspace, target);
|
|
39
|
+
if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
40
|
+
throw new FileFault('path_out_of_scope', 'Path is outside the file workspace');
|
|
41
|
+
}
|
|
42
|
+
const parts = rel.split(path.sep);
|
|
43
|
+
let current = workspace;
|
|
44
|
+
for (let i = 0; i < parts.length; i++) {
|
|
45
|
+
current = path.join(current, parts[i]);
|
|
46
|
+
const final = i === parts.length - 1;
|
|
47
|
+
let stat;
|
|
48
|
+
try {
|
|
49
|
+
stat = await lstat(current);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code === 'ENOENT' && final && allowMissing)
|
|
53
|
+
return target;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
if (stat.isSymbolicLink())
|
|
57
|
+
throw new FileFault('symlink_denied', 'Symlinks are not allowed');
|
|
58
|
+
if (final ? !stat.isFile() || stat.nlink > 1 : !stat.isDirectory()) {
|
|
59
|
+
throw new FileFault('not_regular_file', 'Expected ordinary directories and a single-link regular file');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return target;
|
|
63
|
+
};
|
|
64
|
+
return ['read', 'write'].map(name => {
|
|
65
|
+
const writing = name === 'write';
|
|
66
|
+
return {
|
|
67
|
+
definition: {
|
|
68
|
+
name,
|
|
69
|
+
description: writing ? 'Replace a workspace UTF-8 text file' : 'Read a bounded workspace UTF-8 text file',
|
|
70
|
+
inputSchema: { type: 'object', properties: { path: { type: 'string' },
|
|
71
|
+
...(writing ? { content: { type: 'string' } } : {}) },
|
|
72
|
+
required: writing ? ['path', 'content'] : ['path'], additionalProperties: false },
|
|
73
|
+
},
|
|
74
|
+
validate: (args) => validate(args, writing),
|
|
75
|
+
async execute(args, _scope, signal) {
|
|
76
|
+
const invalid = validate(args, writing);
|
|
77
|
+
if (invalid)
|
|
78
|
+
return { ok: false, error: invalid };
|
|
79
|
+
const { path: relative, content } = args;
|
|
80
|
+
let temporary;
|
|
81
|
+
let committed = false;
|
|
82
|
+
let result;
|
|
83
|
+
try {
|
|
84
|
+
signal.throwIfAborted();
|
|
85
|
+
const target = await check(relative, writing);
|
|
86
|
+
if (!writing) {
|
|
87
|
+
const file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
88
|
+
try {
|
|
89
|
+
const stat = await file.stat();
|
|
90
|
+
if (!stat.isFile() || stat.nlink > 1)
|
|
91
|
+
throw new FileFault('not_regular_file', 'Expected single-link regular file');
|
|
92
|
+
if (stat.size > maxBytes)
|
|
93
|
+
throw new FileFault('too_large', 'File exceeds byte limit');
|
|
94
|
+
const buffer = Buffer.alloc(maxBytes + 1);
|
|
95
|
+
let bytes = 0;
|
|
96
|
+
while (bytes < buffer.length) {
|
|
97
|
+
signal.throwIfAborted();
|
|
98
|
+
const read = await file.read(buffer, bytes, buffer.length - bytes, bytes);
|
|
99
|
+
if (!read.bytesRead)
|
|
100
|
+
break;
|
|
101
|
+
bytes += read.bytesRead;
|
|
102
|
+
}
|
|
103
|
+
if (bytes > maxBytes)
|
|
104
|
+
throw new FileFault('too_large', 'File exceeds byte limit');
|
|
105
|
+
let text;
|
|
106
|
+
try {
|
|
107
|
+
text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(buffer.subarray(0, bytes));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
throw new FileFault('invalid_utf8', 'File is not valid UTF-8');
|
|
111
|
+
}
|
|
112
|
+
result = { ok: true, content: JSON.stringify({ path: relative, text, bytes }) };
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
await file.close();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const candidate = path.join(path.dirname(target), `.agent-${randomUUID()}.tmp`);
|
|
120
|
+
const file = await open(candidate, 'wx', 0o600);
|
|
121
|
+
temporary = candidate;
|
|
122
|
+
try {
|
|
123
|
+
await file.writeFile(content, { encoding: 'utf8', signal });
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
await file.close();
|
|
127
|
+
}
|
|
128
|
+
await check(relative, true);
|
|
129
|
+
signal.throwIfAborted();
|
|
130
|
+
await rename(temporary, target);
|
|
131
|
+
committed = true;
|
|
132
|
+
temporary = undefined;
|
|
133
|
+
result = { ok: true, content: JSON.stringify({ path: relative, bytes: Buffer.byteLength(content) }) };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
const code = error instanceof FileFault ? error.code
|
|
138
|
+
: signal.aborted ? 'cancelled_before_commit'
|
|
139
|
+
: error.code === 'ENOENT' ? 'not_found' : 'io_error';
|
|
140
|
+
result = { ok: false, error: fail(code, error instanceof FileFault ? error.message : `File operation failed (${code})`) };
|
|
141
|
+
}
|
|
142
|
+
if (temporary) {
|
|
143
|
+
try {
|
|
144
|
+
await unlink(temporary);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return { ok: false, error: { code: 'cleanup_failed',
|
|
148
|
+
message: `Owned temporary file remains: ${path.relative(workspace, temporary)}; targetCommitted=${committed}`,
|
|
149
|
+
effect: 'unknown' } };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return result;
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
}
|