@dna113p/machines 0.2.0 → 0.3.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 +97 -4
- package/claude/statusline.mjs +244 -0
- package/dist/examples/chatgpt-web.d.ts +1 -0
- package/dist/examples/chatgpt-web.js +36 -0
- package/dist/examples/claude.d.ts +1 -0
- package/dist/examples/claude.js +30 -0
- package/dist/examples/codex.d.ts +1 -0
- package/dist/examples/codex.js +30 -0
- package/dist/examples/decision.d.ts +1 -0
- package/dist/examples/decision.js +66 -0
- package/dist/examples/deepseek.d.ts +1 -0
- package/dist/examples/deepseek.js +27 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +72 -10
- package/dist/pi-extension/index.js +16 -9
- package/dist/src/acp.js +2 -1
- package/dist/src/agent-bindings.d.ts +6 -0
- package/dist/src/agent-bindings.js +35 -5
- package/dist/src/agent-limit.d.ts +25 -0
- package/dist/src/agent-limit.js +34 -0
- package/dist/src/agent-limits.d.ts +37 -0
- package/dist/src/agent-limits.js +212 -0
- package/dist/src/agent-protocol.js +3 -1
- package/dist/src/catalog.d.ts +3 -0
- package/dist/src/catalog.js +84 -12
- package/dist/src/chatgpt-web.d.ts +143 -0
- package/dist/src/chatgpt-web.js +946 -0
- package/dist/src/claude.d.ts +35 -0
- package/dist/src/claude.js +294 -0
- package/dist/src/cli.js +194 -16
- package/dist/src/codex.d.ts +16 -0
- package/dist/src/codex.js +176 -0
- package/dist/src/conversation.d.ts +74 -0
- package/dist/src/conversation.js +46 -0
- package/dist/src/coordinator-guidance.d.ts +3 -0
- package/dist/src/coordinator-guidance.js +17 -0
- package/dist/src/decision.d.ts +29 -0
- package/dist/src/decision.js +67 -0
- package/dist/src/deepseek.d.ts +12 -0
- package/dist/src/deepseek.js +16 -0
- package/dist/src/host.d.ts +3 -0
- package/dist/src/host.js +68 -7
- package/dist/src/index.d.ts +39 -2
- package/dist/src/index.js +62 -5
- package/dist/src/jev.d.ts +15 -0
- package/dist/src/jev.js +90 -0
- package/dist/src/json.d.ts +6 -0
- package/dist/src/json.js +28 -0
- package/dist/src/launcher.d.ts +11 -1
- package/dist/src/launcher.js +72 -3
- package/dist/src/laya.d.ts +15 -0
- package/dist/src/laya.js +13 -0
- package/dist/src/openrouter.d.ts +16 -0
- package/dist/src/openrouter.js +97 -0
- package/dist/src/run-inbox.d.ts +49 -0
- package/dist/src/run-inbox.js +229 -0
- package/dist/src/run-status.d.ts +97 -0
- package/dist/src/run-status.js +285 -0
- package/dist/src/session.d.ts +13 -0
- package/dist/src/session.js +41 -3
- package/dist/src/system-one-http.d.ts +25 -0
- package/dist/src/system-one-http.js +146 -0
- package/dist/src/terminal-human.d.ts +8 -1
- package/dist/src/terminal-human.js +36 -8
- package/dist/src/von.d.ts +15 -0
- package/dist/src/von.js +13 -0
- package/docs/architecture.md +67 -2
- package/docs/authoring.md +809 -1
- package/docs/integrations.md +243 -3
- package/docs/local-decisions.md +212 -0
- package/package.json +21 -4
- package/services/laya/server.py +230 -0
- package/skills/machine-builder/SKILL.md +56 -6
- package/skills/machine-delegation/SKILL.md +123 -0
- package/skills/machine-delegation/references/delegation-machine.md +90 -0
- package/skills/machine-project-setup/SKILL.md +181 -0
- package/skills/machine-project-setup/references/project-workflows.md +50 -0
package/dist/mcp/server.js
CHANGED
|
@@ -6,15 +6,19 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
6
6
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
7
|
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
8
8
|
import * as v from "valibot";
|
|
9
|
+
import { isJsonValue } from "../src/json.js";
|
|
9
10
|
import { MachineSession as RunSession } from "../src/session.js";
|
|
10
11
|
import { listAgentPresets, listMachines } from "../src/launcher.js";
|
|
12
|
+
import { watchRunInbox } from "../src/run-inbox.js";
|
|
13
|
+
import { createRunStatusPublisher, runStatusDirectory } from "../src/run-status.js";
|
|
14
|
+
import { machineCoordinatorGuidelines, machineDiscoveryDescription, machineSupervisionGuidelines } from "../src/coordinator-guidance.js";
|
|
11
15
|
const widgetUri = "ui://machines/run-v1.html";
|
|
12
16
|
const cwd = v.pipe(v.string(), v.nonEmpty("cwd must be a non-empty absolute path"), v.check(isAbsolute, "cwd must be an absolute path"));
|
|
13
17
|
const listInput = v.strictObject({ cwd });
|
|
14
18
|
const startInput = v.strictObject({
|
|
15
19
|
cwd,
|
|
16
20
|
machine: v.pipe(v.string(), v.nonEmpty("machine must not be empty")),
|
|
17
|
-
input: v.optional(v.
|
|
21
|
+
input: v.optional(v.custom(isJsonValue)),
|
|
18
22
|
agents: v.optional(v.record(v.pipe(v.string(), v.nonEmpty("Agent role names must not be empty")), v.pipe(v.string(), v.nonEmpty("Agent preset names must not be empty")))),
|
|
19
23
|
});
|
|
20
24
|
const statusInput = v.strictObject({
|
|
@@ -40,7 +44,7 @@ const tools = [
|
|
|
40
44
|
{
|
|
41
45
|
name: "machine_list",
|
|
42
46
|
title: "List Machines",
|
|
43
|
-
description:
|
|
47
|
+
description: machineDiscoveryDescription,
|
|
44
48
|
inputSchema: locationInputSchema,
|
|
45
49
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
46
50
|
},
|
|
@@ -67,8 +71,7 @@ const tools = [
|
|
|
67
71
|
description: "Exact Machine name returned by machine_list",
|
|
68
72
|
},
|
|
69
73
|
input: {
|
|
70
|
-
|
|
71
|
-
description: "Clear task or input for the Machine",
|
|
74
|
+
description: "JSON-serializable task or structured input for the Machine",
|
|
72
75
|
},
|
|
73
76
|
agents: {
|
|
74
77
|
type: "object",
|
|
@@ -135,6 +138,13 @@ const tools = [
|
|
|
135
138
|
];
|
|
136
139
|
export class MachineSession {
|
|
137
140
|
#session = new RunSession();
|
|
141
|
+
#published = new Map();
|
|
142
|
+
#inboxes = new Map();
|
|
143
|
+
/** The inbox delivery being handed to the session, named in the update that leaves its request. */
|
|
144
|
+
#taking;
|
|
145
|
+
constructor() {
|
|
146
|
+
this.#session.subscribe(({ run }) => this.#publish(run));
|
|
147
|
+
}
|
|
138
148
|
async list(input) {
|
|
139
149
|
const { cwd } = parse(listInput, input);
|
|
140
150
|
const machines = await listMachines({ cwd });
|
|
@@ -160,7 +170,8 @@ export class MachineSession {
|
|
|
160
170
|
const identity = [agent.harness, agent.model, agent.thinking]
|
|
161
171
|
.filter((part) => part !== undefined)
|
|
162
172
|
.join(" · ");
|
|
163
|
-
|
|
173
|
+
const fallback = agent.fallback === undefined ? "" : ` · fallback: ${agent.fallback.join(", ") || "none"}`;
|
|
174
|
+
return `${agent.name} — ${agent.description}${identity === "" ? "" : ` (${identity})`}\n ${agent.source}${fallback}`;
|
|
164
175
|
}).join("\n\n");
|
|
165
176
|
return result(text, { agents });
|
|
166
177
|
}
|
|
@@ -179,6 +190,58 @@ export class MachineSession {
|
|
|
179
190
|
}
|
|
180
191
|
close() {
|
|
181
192
|
this.#session.close();
|
|
193
|
+
// Closing terminates the session's runs without a final event.
|
|
194
|
+
for (const publisher of this.#published.values())
|
|
195
|
+
publisher.finish({ error: "Machine session closed" });
|
|
196
|
+
this.#published.clear();
|
|
197
|
+
for (const inbox of this.#inboxes.values())
|
|
198
|
+
inbox.stop();
|
|
199
|
+
this.#inboxes.clear();
|
|
200
|
+
}
|
|
201
|
+
#publish(run) {
|
|
202
|
+
let publisher = this.#published.get(run.id);
|
|
203
|
+
if (publisher === undefined) {
|
|
204
|
+
publisher = createRunStatusPublisher({
|
|
205
|
+
id: run.id,
|
|
206
|
+
machine: run.machine,
|
|
207
|
+
path: run.path,
|
|
208
|
+
cwd: run.cwd,
|
|
209
|
+
startedAt: run.startedAt,
|
|
210
|
+
});
|
|
211
|
+
this.#published.set(run.id, publisher);
|
|
212
|
+
}
|
|
213
|
+
const human = run.status === "waiting" ? run.human : undefined;
|
|
214
|
+
this.#watchInbox(run.id, human);
|
|
215
|
+
if (run.status === "running" || run.status === "waiting") {
|
|
216
|
+
publisher.update({
|
|
217
|
+
status: run.status, state: run.state, agent: run.agent, limit: run.limit, human, delivery: this.#taking,
|
|
218
|
+
});
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
publisher.finish(run.status === "completed" ? { state: run.state } : { error: run.error ?? "Machine run failed" });
|
|
222
|
+
this.#published.delete(run.id);
|
|
223
|
+
}
|
|
224
|
+
/** A published request can also be answered, or asked about, through the run's inbox by `machine respond`. */
|
|
225
|
+
#watchInbox(runId, request) {
|
|
226
|
+
const watched = this.#inboxes.get(runId);
|
|
227
|
+
if (watched?.requestId === request?.requestId)
|
|
228
|
+
return;
|
|
229
|
+
watched?.stop();
|
|
230
|
+
this.#inboxes.delete(runId);
|
|
231
|
+
const directory = runStatusDirectory();
|
|
232
|
+
if (request === undefined || directory === undefined)
|
|
233
|
+
return;
|
|
234
|
+
const stop = watchRunInbox(directory, runId, request, (response, delivery) => {
|
|
235
|
+
// The session leaves the request before either call returns. A tool call
|
|
236
|
+
// may have answered first; the session then refuses this one unpublished.
|
|
237
|
+
this.#taking = delivery;
|
|
238
|
+
const sent = typeof response === "string"
|
|
239
|
+
? this.#session.respond({ runId, requestId: request.requestId, response })
|
|
240
|
+
: this.#session.ask({ runId, requestId: request.requestId, question: response.text });
|
|
241
|
+
this.#taking = undefined;
|
|
242
|
+
sent.catch(() => { });
|
|
243
|
+
});
|
|
244
|
+
this.#inboxes.set(runId, { requestId: request.requestId, stop });
|
|
182
245
|
}
|
|
183
246
|
}
|
|
184
247
|
export function createMachinesMcpServer(widgetHtml = readFileSync(new URL("../codex/machines/ui/machines.html", import.meta.url), "utf8")) {
|
|
@@ -186,11 +249,8 @@ export function createMachinesMcpServer(widgetHtml = readFileSync(new URL("../co
|
|
|
186
249
|
const server = new Server({ name: "machines", version: "0.1.0" }, {
|
|
187
250
|
capabilities: { tools: {}, resources: {} },
|
|
188
251
|
instructions: [
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
"machine_start is asynchronous; continue the conversation while it runs.",
|
|
192
|
-
"Use machine_status for authoritative progress and machine_respond only after the Human's answer is clear.",
|
|
193
|
-
"If no Machine fits a one-off task, handle it normally. Ask before creating persistent Machine policy.",
|
|
252
|
+
...machineCoordinatorGuidelines,
|
|
253
|
+
...machineSupervisionGuidelines,
|
|
194
254
|
].join(" "),
|
|
195
255
|
});
|
|
196
256
|
server.onclose = () => session.close();
|
|
@@ -264,6 +324,8 @@ function formatSnapshot(run) {
|
|
|
264
324
|
run.agent.thinking === undefined ? undefined : `thinking ${run.agent.thinking}`,
|
|
265
325
|
].filter((part) => part !== undefined).join(" · ")}`);
|
|
266
326
|
}
|
|
327
|
+
if (run.limit !== undefined)
|
|
328
|
+
lines.push(`Limit: ${run.limit.text}`);
|
|
267
329
|
if (run.human !== undefined) {
|
|
268
330
|
lines.push(`Human: ${run.human.prompt}`, `Request id: ${run.human.requestId}`);
|
|
269
331
|
if (run.human.choices !== undefined)
|
|
@@ -10,6 +10,8 @@ import { realpathSync } from "node:fs";
|
|
|
10
10
|
import { dirname, resolve } from "node:path";
|
|
11
11
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
12
|
import { Type } from "typebox";
|
|
13
|
+
import { assertJsonValue } from "../src/json.js";
|
|
14
|
+
import { machineCoordinatorGuidelines, machineDiscoveryDescription } from "../src/coordinator-guidance.js";
|
|
13
15
|
const sourceFile = realpathSync(fileURLToPath(import.meta.url));
|
|
14
16
|
const sourceRoot = resolve(dirname(sourceFile), "..");
|
|
15
17
|
const moduleExtension = sourceFile.endsWith(".ts") ? "ts" : "js";
|
|
@@ -105,12 +107,9 @@ export default function machinesExtension(pi) {
|
|
|
105
107
|
pi.registerTool({
|
|
106
108
|
name: "machine_list",
|
|
107
109
|
label: "List Machines",
|
|
108
|
-
description:
|
|
109
|
-
promptSnippet: "Discover
|
|
110
|
-
promptGuidelines:
|
|
111
|
-
"Use machine_list when the user asks to run a Machine or a reusable multi-step workflow may fit; choose an existing Machine by its description rather than guessing its name.",
|
|
112
|
-
"If no Machine fits, handle a one-off task normally. For a reusable workflow, propose a short state sequence and ask before creating persistent Machine policy.",
|
|
113
|
-
],
|
|
110
|
+
description: machineDiscoveryDescription,
|
|
111
|
+
promptSnippet: "Discover and use Machines before delegated or multi-step work.",
|
|
112
|
+
promptGuidelines: machineCoordinatorGuidelines,
|
|
114
113
|
parameters: Type.Object({}, { additionalProperties: false }),
|
|
115
114
|
executionMode: "parallel",
|
|
116
115
|
async execute(_id, _params, _signal, _onUpdate, context) {
|
|
@@ -156,7 +155,8 @@ export default function machinesExtension(pi) {
|
|
|
156
155
|
const identity = [agent.harness, agent.model, agent.thinking]
|
|
157
156
|
.filter((part) => part !== undefined)
|
|
158
157
|
.join(" · ");
|
|
159
|
-
|
|
158
|
+
const fallback = agent.fallback === undefined ? "" : ` · fallback: ${agent.fallback.join(", ") || "none"}`;
|
|
159
|
+
return `${agent.name} — ${agent.description}${identity === "" ? "" : ` (${identity})`}\n ${agent.source}${fallback}`;
|
|
160
160
|
}).join("\n");
|
|
161
161
|
return result(text, {
|
|
162
162
|
summary: `${agents.length} Agent preset${agents.length === 1 ? "" : "s"}`,
|
|
@@ -177,13 +177,15 @@ export default function machinesExtension(pi) {
|
|
|
177
177
|
],
|
|
178
178
|
parameters: Type.Object({
|
|
179
179
|
machine: Type.String({ description: "Exact Machine name from machine_list" }),
|
|
180
|
-
input: Type.Optional(Type.
|
|
180
|
+
input: Type.Optional(Type.Unknown({ description: "JSON-serializable task or structured input for the Machine" })),
|
|
181
181
|
agents: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Optional Machine role to configured Agent preset name" })),
|
|
182
182
|
}, { additionalProperties: false }),
|
|
183
183
|
async execute(_id, rawParams, _signal, _onUpdate, context) {
|
|
184
184
|
useContext(context);
|
|
185
185
|
const machine = requiredString(rawParams.machine, "machine");
|
|
186
|
-
const input =
|
|
186
|
+
const input = rawParams.input;
|
|
187
|
+
if (input !== undefined)
|
|
188
|
+
assertJsonValue(input, "Machine input");
|
|
187
189
|
const agents = optionalStringRecord(rawParams.agents, "agents");
|
|
188
190
|
const runs = await getSession();
|
|
189
191
|
const run = await runs.start({
|
|
@@ -263,6 +265,8 @@ function formatSnapshot(run) {
|
|
|
263
265
|
];
|
|
264
266
|
if (run.agent !== undefined)
|
|
265
267
|
lines.push(`Agent: ${formatAgent(run.agent)}`);
|
|
268
|
+
if (run.limit !== undefined)
|
|
269
|
+
lines.push(`Limit: ${run.limit.text}`);
|
|
266
270
|
if (run.human !== undefined) {
|
|
267
271
|
lines.push(`Human: ${run.human.prompt}`, `Request id: ${run.human.requestId}`);
|
|
268
272
|
if (run.human.choices !== undefined)
|
|
@@ -314,6 +318,9 @@ function widgetLines(runs, theme, width) {
|
|
|
314
318
|
lines.push(`${edge("│")} ${theme.fg("warning", promptLine)}`);
|
|
315
319
|
}
|
|
316
320
|
}
|
|
321
|
+
else if (run.limit !== undefined) {
|
|
322
|
+
lines.push(`${edge("│")} ${theme.fg("warning", run.limit.text)}`);
|
|
323
|
+
}
|
|
317
324
|
else if (run.agent !== undefined) {
|
|
318
325
|
lines.push(`${edge("│")} ${theme.fg("muted", formatAgent(run.agent))}`);
|
|
319
326
|
}
|
package/dist/src/acp.js
CHANGED
|
@@ -21,7 +21,8 @@ export function acpAgent(command, args = [], options = {}) {
|
|
|
21
21
|
child.stderr.setEncoding("utf8");
|
|
22
22
|
child.stderr.on("data", (chunk) => {
|
|
23
23
|
stderr += chunk;
|
|
24
|
-
|
|
24
|
+
if (options.output !== "capture")
|
|
25
|
+
process.stderr.write(chunk);
|
|
25
26
|
});
|
|
26
27
|
try {
|
|
27
28
|
await waitForSpawn(child);
|
|
@@ -5,11 +5,17 @@ export interface AgentPreset {
|
|
|
5
5
|
readonly harness?: string;
|
|
6
6
|
readonly model?: string;
|
|
7
7
|
readonly thinking?: string;
|
|
8
|
+
/** Presets that take over at a usage limit, in order, instead of the shared list. `[]` never swaps. */
|
|
9
|
+
readonly fallback?: readonly string[];
|
|
8
10
|
}
|
|
9
11
|
export interface LoadedAgentBindings {
|
|
10
12
|
readonly agents: Readonly<Record<string, AgentRunner>>;
|
|
11
13
|
readonly presets: Readonly<Record<string, AgentPreset>>;
|
|
12
14
|
readonly sources: Readonly<Record<string, string>>;
|
|
15
|
+
/** The shared fallback list from the nearest file that exports one. */
|
|
16
|
+
readonly fallback?: readonly string[];
|
|
13
17
|
}
|
|
14
18
|
export type AgentAdapters = Readonly<Record<string, unknown>>;
|
|
15
19
|
export declare function loadAgentBindings(cwd?: string, home?: string, adapters?: AgentAdapters): Promise<LoadedAgentBindings>;
|
|
20
|
+
/** The presets that take over from one preset at a usage limit: its own chain, else the shared list without itself. */
|
|
21
|
+
export declare function fallbackPresets(name: string, preset: Pick<AgentPreset, "fallback">, shared: readonly string[] | undefined): readonly string[] | undefined;
|
|
@@ -10,7 +10,7 @@ import { stat } from "node:fs/promises";
|
|
|
10
10
|
import { join, resolve } from "node:path";
|
|
11
11
|
import { pathToFileURL } from "node:url";
|
|
12
12
|
import { machinesUserHome, nearestMachinesDirectory } from "./discovery.js";
|
|
13
|
-
const presetFields = new Set(["description", "runner", "harness", "model", "thinking"]);
|
|
13
|
+
const presetFields = new Set(["description", "runner", "harness", "model", "thinking", "fallback"]);
|
|
14
14
|
export async function loadAgentBindings(cwd = process.cwd(), home = machinesUserHome(), adapters = {}) {
|
|
15
15
|
const globalPath = join(home, ".machines", "agents.ts");
|
|
16
16
|
const projectDirectory = await nearestMachinesDirectory(cwd);
|
|
@@ -23,17 +23,24 @@ export async function loadAgentBindings(cwd = process.cwd(), home = machinesUser
|
|
|
23
23
|
const agents = {};
|
|
24
24
|
const presets = {};
|
|
25
25
|
const sources = {};
|
|
26
|
+
let fallback;
|
|
26
27
|
for (const path of paths) {
|
|
27
28
|
if (!await isFile(path))
|
|
28
29
|
continue;
|
|
29
|
-
const
|
|
30
|
-
for (const [name, preset] of Object.entries(
|
|
30
|
+
const loaded = await loadBindingFile(path, adapters);
|
|
31
|
+
for (const [name, preset] of Object.entries(loaded.presets)) {
|
|
31
32
|
agents[name] = preset.runner;
|
|
32
33
|
presets[name] = preset;
|
|
33
34
|
sources[name] = path;
|
|
34
35
|
}
|
|
36
|
+
// The nearer file's list replaces the other; the lists are never merged.
|
|
37
|
+
fallback = loaded.fallback ?? fallback;
|
|
35
38
|
}
|
|
36
|
-
return { agents, presets, sources };
|
|
39
|
+
return { agents, presets, sources, ...(fallback === undefined ? {} : { fallback }) };
|
|
40
|
+
}
|
|
41
|
+
/** The presets that take over from one preset at a usage limit: its own chain, else the shared list without itself. */
|
|
42
|
+
export function fallbackPresets(name, preset, shared) {
|
|
43
|
+
return preset.fallback ?? shared?.filter((other) => other !== name);
|
|
37
44
|
}
|
|
38
45
|
async function loadBindingFile(path, adapters) {
|
|
39
46
|
let loaded;
|
|
@@ -82,9 +89,32 @@ async function loadBindingFile(path, adapters) {
|
|
|
82
89
|
...optionalField(candidate.harness, "harness", name, path),
|
|
83
90
|
...optionalField(candidate.model, "model", name, path),
|
|
84
91
|
...optionalField(candidate.thinking, "thinking", name, path),
|
|
92
|
+
...(candidate.fallback === undefined
|
|
93
|
+
? {}
|
|
94
|
+
: { fallback: fallbackNames(candidate.fallback, `Agent preset "${name}" fallback in "${path}"`, name) }),
|
|
85
95
|
};
|
|
86
96
|
}
|
|
87
|
-
|
|
97
|
+
const shared = loaded.fallback;
|
|
98
|
+
return {
|
|
99
|
+
presets: bindings,
|
|
100
|
+
...(shared === undefined ? {} : { fallback: fallbackNames(shared, `Agent bindings "${path}" fallback export`) }),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function fallbackNames(value, subject, self) {
|
|
104
|
+
if (!Array.isArray(value))
|
|
105
|
+
throw new Error(`${subject} must be an array of preset names`);
|
|
106
|
+
const names = [];
|
|
107
|
+
for (const name of value) {
|
|
108
|
+
if (typeof name !== "string" || name.trim() === "" || name !== name.trim() || /\r|\n|\t/u.test(name)) {
|
|
109
|
+
throw new Error(`${subject} must contain only non-empty, trimmed, one-line preset names`);
|
|
110
|
+
}
|
|
111
|
+
if (names.includes(name))
|
|
112
|
+
throw new Error(`${subject} names "${name}" more than once`);
|
|
113
|
+
if (name === self)
|
|
114
|
+
throw new Error(`${subject} must not name the preset itself`);
|
|
115
|
+
names.push(name);
|
|
116
|
+
}
|
|
117
|
+
return names;
|
|
88
118
|
}
|
|
89
119
|
function optionalField(value, field, name, path) {
|
|
90
120
|
return value === undefined
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** A provider usage limit that ended an Agent turn. */
|
|
2
|
+
export interface AgentLimit {
|
|
3
|
+
/** The runner's harness label, for example "claude" or "codex". */
|
|
4
|
+
readonly harness: string;
|
|
5
|
+
/** The preset name. Runners leave it unset; the caller that selected the preset fills it in. */
|
|
6
|
+
readonly agent?: string;
|
|
7
|
+
/** The provider's name for the limit when known, for example "five_hour", "seven_day_opus", or "usage". */
|
|
8
|
+
readonly kind?: string;
|
|
9
|
+
/** Epoch milliseconds, only when the provider states when the limit resets. */
|
|
10
|
+
readonly resetsAt?: number;
|
|
11
|
+
}
|
|
12
|
+
declare const brand: unique symbol;
|
|
13
|
+
/** Thrown by an Agent runner when the provider refuses a turn because a usage limit is reached. */
|
|
14
|
+
export declare class AgentLimitError extends Error {
|
|
15
|
+
readonly limit: AgentLimit;
|
|
16
|
+
/** Every limit behind the failure when several presets were tried, each naming its preset in `agent`. */
|
|
17
|
+
readonly limits?: readonly AgentLimit[];
|
|
18
|
+
readonly [brand] = true;
|
|
19
|
+
constructor(message: string, limit: AgentLimit, options?: ErrorOptions & {
|
|
20
|
+
readonly limits?: readonly AgentLimit[];
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/** Returns the usage limit behind a failure, searching its `cause` chain. */
|
|
24
|
+
export declare function agentLimitOf(cause: unknown): AgentLimit | undefined;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// A registered symbol identifies the error across module copies (source, `dist`,
|
|
2
|
+
// another package's installed copy), where `instanceof` would not.
|
|
3
|
+
const brand = Symbol.for("machines.agent-limit");
|
|
4
|
+
const maximumCauseDepth = 8;
|
|
5
|
+
/** Thrown by an Agent runner when the provider refuses a turn because a usage limit is reached. */
|
|
6
|
+
export class AgentLimitError extends Error {
|
|
7
|
+
limit;
|
|
8
|
+
/** Every limit behind the failure when several presets were tried, each naming its preset in `agent`. */
|
|
9
|
+
limits;
|
|
10
|
+
[brand] = true;
|
|
11
|
+
constructor(message, limit, options) {
|
|
12
|
+
super(message, options);
|
|
13
|
+
this.name = "AgentLimitError";
|
|
14
|
+
this.limit = limit;
|
|
15
|
+
if (options?.limits !== undefined)
|
|
16
|
+
this.limits = options.limits;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Returns the usage limit behind a failure, searching its `cause` chain. */
|
|
20
|
+
export function agentLimitOf(cause) {
|
|
21
|
+
let current = cause;
|
|
22
|
+
for (let depth = 0; depth < maximumCauseDepth; depth += 1) {
|
|
23
|
+
if (current === null || typeof current !== "object")
|
|
24
|
+
return undefined;
|
|
25
|
+
if (brand in current && current[brand] === true && "limit" in current && isAgentLimit(current.limit)) {
|
|
26
|
+
return current.limit;
|
|
27
|
+
}
|
|
28
|
+
current = "cause" in current ? current.cause : undefined;
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
function isAgentLimit(value) {
|
|
33
|
+
return value !== null && typeof value === "object" && "harness" in value && typeof value.harness === "string";
|
|
34
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type AgentLimit } from "./agent-limit.ts";
|
|
2
|
+
import type { AgentRunner } from "./index.ts";
|
|
3
|
+
export interface LimitPolicy {
|
|
4
|
+
/** Longest one invocation waits for a reset, from its first wait. `0` never waits. */
|
|
5
|
+
readonly maxWaitMs: number;
|
|
6
|
+
/** How long a limit with no stated reset is trusted before the request is tried again. */
|
|
7
|
+
readonly probeMs: number;
|
|
8
|
+
readonly now?: () => number;
|
|
9
|
+
/** A replacement must let `now` advance by about the time it was asked to sleep. */
|
|
10
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export interface LimitCandidate {
|
|
13
|
+
/** The preset name. */
|
|
14
|
+
readonly name: string;
|
|
15
|
+
readonly runner: AgentRunner;
|
|
16
|
+
readonly harness?: string;
|
|
17
|
+
}
|
|
18
|
+
interface RememberedLimit {
|
|
19
|
+
readonly limit: AgentLimit;
|
|
20
|
+
/** When the preset is worth trying again. */
|
|
21
|
+
readonly until: number;
|
|
22
|
+
/** Whether `until` is the provider's stated reset rather than the next probe. */
|
|
23
|
+
readonly known: boolean;
|
|
24
|
+
}
|
|
25
|
+
/** Presets known to be at a limit, by name. One per prepared run, shared by its roles, never stored. */
|
|
26
|
+
export type LimitMemory = Map<string, RememberedLimit>;
|
|
27
|
+
export declare const defaultProbeMs: number;
|
|
28
|
+
export declare function createLimitMemory(): LimitMemory;
|
|
29
|
+
/**
|
|
30
|
+
* Runs one role's request on the first candidate that is not at a usage limit.
|
|
31
|
+
* A limited candidate hands the same request to the next one; when all are
|
|
32
|
+
* limited the request waits for a reset within the policy, or fails.
|
|
33
|
+
* A request with `limits: "report"` is tried on the bound candidate alone, and
|
|
34
|
+
* its limit is thrown at once.
|
|
35
|
+
*/
|
|
36
|
+
export declare function limitAwareRunner(role: string, candidates: readonly LimitCandidate[], memory: LimitMemory, policy: LimitPolicy): AgentRunner;
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { AgentLimitError, agentLimitOf } from "./agent-limit.js";
|
|
2
|
+
export const defaultProbeMs = 15 * 60_000;
|
|
3
|
+
// A provider's reset time is when its window ends, not when it accepts requests again.
|
|
4
|
+
const resetMarginMs = 60_000;
|
|
5
|
+
// Short sleeps measured against the clock notice a reset that passed while the computer was suspended.
|
|
6
|
+
const tickMs = 60_000;
|
|
7
|
+
const handoffNote = [
|
|
8
|
+
"Note: a previous Agent working on this assignment was interrupted by a usage limit and may have",
|
|
9
|
+
"left partial work in the workspace. Inspect the current state and continue the same assignment",
|
|
10
|
+
"rather than starting over.",
|
|
11
|
+
].join(" ");
|
|
12
|
+
export function createLimitMemory() {
|
|
13
|
+
return new Map();
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Runs one role's request on the first candidate that is not at a usage limit.
|
|
17
|
+
* A limited candidate hands the same request to the next one; when all are
|
|
18
|
+
* limited the request waits for a reset within the policy, or fails.
|
|
19
|
+
* A request with `limits: "report"` is tried on the bound candidate alone, and
|
|
20
|
+
* its limit is thrown at once.
|
|
21
|
+
*/
|
|
22
|
+
export function limitAwareRunner(role, candidates, memory, policy) {
|
|
23
|
+
const bound = candidates[0];
|
|
24
|
+
if (bound === undefined)
|
|
25
|
+
throw new Error(`Agent role "${role}" needs at least one preset`);
|
|
26
|
+
const now = policy.now ?? Date.now;
|
|
27
|
+
const sleep = policy.sleep ?? timerSleep;
|
|
28
|
+
const named = (name) => name === role ? name : `${name} (role ${role})`;
|
|
29
|
+
const limited = (name, retrying) => {
|
|
30
|
+
const remembered = memory.get(name);
|
|
31
|
+
return remembered !== undefined && remembered.until > now() && retrying?.has(remembered) !== true;
|
|
32
|
+
};
|
|
33
|
+
// The state decides what its limit means, so nothing is swapped, waited for, or reported as an update.
|
|
34
|
+
const reported = async (request, report) => {
|
|
35
|
+
const known = memory.get(bound.name);
|
|
36
|
+
if (known !== undefined && known.until > now()) {
|
|
37
|
+
throw new AgentLimitError(`${named(bound.name)} is at its usage limit (${resetText(known)})`, known.limit);
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const event = await bound.runner(request, report);
|
|
41
|
+
if (known !== undefined && memory.get(bound.name) === known)
|
|
42
|
+
memory.delete(bound.name);
|
|
43
|
+
return event;
|
|
44
|
+
}
|
|
45
|
+
catch (cause) {
|
|
46
|
+
const limit = agentLimitOf(cause);
|
|
47
|
+
if (limit === undefined)
|
|
48
|
+
throw cause;
|
|
49
|
+
// The run's other roles skip this preset, and the limit names it.
|
|
50
|
+
throw new AgentLimitError(cause instanceof Error ? cause.message : String(cause), remember(bound, limit).limit, { cause });
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
return async (request, report) => {
|
|
54
|
+
if (request.limits === "report")
|
|
55
|
+
return reported(request, report);
|
|
56
|
+
// A state is announced after its Agent starts, in the same tick. Observers
|
|
57
|
+
// attach an update to the announced state, so none is reported before then.
|
|
58
|
+
const tell = async (update) => {
|
|
59
|
+
await Promise.resolve();
|
|
60
|
+
report?.(update);
|
|
61
|
+
};
|
|
62
|
+
let interrupted = false;
|
|
63
|
+
let lastFailure;
|
|
64
|
+
let deadline;
|
|
65
|
+
// A wait is reported once, and lasts until a candidate gets past its limit.
|
|
66
|
+
let waiting = false;
|
|
67
|
+
let retrying = new Set();
|
|
68
|
+
for (;;) {
|
|
69
|
+
let passedOver;
|
|
70
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
71
|
+
if (limited(candidate.name, retrying)) {
|
|
72
|
+
passedOver ??= candidate;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (passedOver !== undefined && !interrupted && !waiting) {
|
|
76
|
+
// Another role of this run found the limit, so this one names the preset it runs on instead.
|
|
77
|
+
// After a wait the preset is named when the Agent resumes.
|
|
78
|
+
await tell(swapped(named(passedOver.name), "is at", memory.get(passedOver.name), passedOver, candidate));
|
|
79
|
+
}
|
|
80
|
+
passedOver = undefined;
|
|
81
|
+
const resume = () => {
|
|
82
|
+
if (!waiting)
|
|
83
|
+
return;
|
|
84
|
+
waiting = false;
|
|
85
|
+
report?.({
|
|
86
|
+
type: "limit",
|
|
87
|
+
status: "resumed",
|
|
88
|
+
agent: bound.name,
|
|
89
|
+
...(candidate === bound ? {} : { to: candidate.name }),
|
|
90
|
+
text: `${named(bound.name)} waited for a usage limit to reset; continuing${candidate === bound ? "" : ` with ${candidate.name}`}`,
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
const tried = memory.get(candidate.name);
|
|
94
|
+
let limit;
|
|
95
|
+
try {
|
|
96
|
+
const event = await candidate.runner(interrupted ? withHandoffNote(request) : request,
|
|
97
|
+
// A limited turn can still report its identity and the provider's message; a tool call is work.
|
|
98
|
+
!waiting ? report : (update) => {
|
|
99
|
+
if (update.type === "tool")
|
|
100
|
+
resume();
|
|
101
|
+
report?.(update);
|
|
102
|
+
});
|
|
103
|
+
resume();
|
|
104
|
+
// What was remembered for a preset that answers is out of date, also for the run's other roles.
|
|
105
|
+
if (tried !== undefined && memory.get(candidate.name) === tried)
|
|
106
|
+
memory.delete(candidate.name);
|
|
107
|
+
return event;
|
|
108
|
+
}
|
|
109
|
+
catch (cause) {
|
|
110
|
+
limit = agentLimitOf(cause);
|
|
111
|
+
if (limit === undefined)
|
|
112
|
+
throw cause;
|
|
113
|
+
lastFailure = cause;
|
|
114
|
+
}
|
|
115
|
+
interrupted = true;
|
|
116
|
+
const remembered = remember(candidate, limit);
|
|
117
|
+
const next = candidates.slice(index + 1).find((later) => !limited(later.name, retrying));
|
|
118
|
+
// Also while a wait is reported: each probe that moves on to another preset is a swap.
|
|
119
|
+
if (next !== undefined)
|
|
120
|
+
await tell(swapped(named(candidate.name), "reached", remembered, candidate, next));
|
|
121
|
+
}
|
|
122
|
+
const limits = candidates.flatMap((candidate) => {
|
|
123
|
+
const remembered = memory.get(candidate.name);
|
|
124
|
+
return remembered === undefined ? [] : [{ candidate, remembered }];
|
|
125
|
+
});
|
|
126
|
+
const started = now();
|
|
127
|
+
deadline ??= started + policy.maxWaitMs;
|
|
128
|
+
const closes = deadline;
|
|
129
|
+
const wakes = policy.maxWaitMs <= 0 ? [] : limits.flatMap(({ remembered }) => {
|
|
130
|
+
if (remembered.known) {
|
|
131
|
+
return remembered.until <= closes ? [{ remembered, at: remembered.until + resetMarginMs }] : [];
|
|
132
|
+
}
|
|
133
|
+
// The last probe of a limit with no stated reset is at the deadline.
|
|
134
|
+
return started < closes ? [{ remembered, at: Math.min(remembered.until, closes) }] : [];
|
|
135
|
+
});
|
|
136
|
+
if (wakes.length === 0)
|
|
137
|
+
throw exhausted(role, limits, policy.maxWaitMs, lastFailure);
|
|
138
|
+
const wakeAt = Math.min(...wakes.map((wake) => wake.at));
|
|
139
|
+
// A reset that passed while a later candidate was working needs no wait.
|
|
140
|
+
if (!waiting && wakeAt > now()) {
|
|
141
|
+
waiting = true;
|
|
142
|
+
const resets = wakes.filter((wake) => wake.remembered.known).map((wake) => wake.remembered.until);
|
|
143
|
+
const until = timestamp(resets.length === 0 ? closes : Math.min(...resets));
|
|
144
|
+
const others = candidates.slice(1).map((candidate) => candidate.name);
|
|
145
|
+
await tell({
|
|
146
|
+
type: "limit",
|
|
147
|
+
status: "waiting",
|
|
148
|
+
agent: bound.name,
|
|
149
|
+
until,
|
|
150
|
+
text: `${named(bound.name)}${others.length === 0
|
|
151
|
+
? " reached its usage limit"
|
|
152
|
+
: ` and its fallback${others.length === 1 ? "" : "s"} (${others.join(", ")}) reached their usage limits`}; ${resets.length === 0 ? `no reset is stated, retrying until ${until}` : `waiting for the reset at ${until}`}`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
for (let remaining = wakeAt - now(); remaining > 0; remaining = wakeAt - now()) {
|
|
156
|
+
await sleep(Math.min(remaining, tickMs));
|
|
157
|
+
}
|
|
158
|
+
retrying = new Set(wakes.filter((wake) => wake.at <= wakeAt).map((wake) => wake.remembered));
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
function remember(candidate, limit) {
|
|
162
|
+
const at = now();
|
|
163
|
+
// A reset that has already passed says nothing about when to try again.
|
|
164
|
+
const known = limit.resetsAt !== undefined && limit.resetsAt > at;
|
|
165
|
+
const remembered = {
|
|
166
|
+
limit: { ...limit, agent: candidate.name },
|
|
167
|
+
until: known ? limit.resetsAt : at + policy.probeMs,
|
|
168
|
+
known,
|
|
169
|
+
};
|
|
170
|
+
memory.set(candidate.name, remembered);
|
|
171
|
+
return remembered;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function withHandoffNote(request) {
|
|
175
|
+
return { ...request, prompt: `${request.prompt}\n\n${handoffNote}` };
|
|
176
|
+
}
|
|
177
|
+
function swapped(subject, verb, remembered, from, to) {
|
|
178
|
+
return {
|
|
179
|
+
type: "limit",
|
|
180
|
+
status: "swapped",
|
|
181
|
+
agent: from.name,
|
|
182
|
+
to: to.name,
|
|
183
|
+
text: `${subject} ${verb} its usage limit (${resetText(remembered)}); continuing with ${to.name}`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function exhausted(role, limits, maxWaitMs, cause) {
|
|
187
|
+
const described = limits.map(({ candidate, remembered }) => {
|
|
188
|
+
const details = [candidate.harness ?? remembered.limit.harness, remembered.limit.kind, resetText(remembered)];
|
|
189
|
+
return `${candidate.name} (${details.filter((detail) => detail !== undefined).join(", ")})`;
|
|
190
|
+
});
|
|
191
|
+
const reason = maxWaitMs <= 0
|
|
192
|
+
? "waiting for a reset is off"
|
|
193
|
+
: `none is available within the ${formatMinutes(maxWaitMs)} wait limit`;
|
|
194
|
+
const known = limits.filter(({ remembered }) => remembered.known)
|
|
195
|
+
.sort((left, right) => left.remembered.until - right.remembered.until);
|
|
196
|
+
const earliest = (known[0] ?? limits[0])?.remembered.limit ?? { harness: "unknown" };
|
|
197
|
+
return new AgentLimitError(`Agent role "${role}" is at a usage limit on every allowed preset: ${described.join("; ")}; ${reason}`, earliest, { limits: limits.map(({ remembered }) => remembered.limit), ...(cause === undefined ? {} : { cause }) });
|
|
198
|
+
}
|
|
199
|
+
function resetText(remembered) {
|
|
200
|
+
return remembered?.known === true ? `resets ${timestamp(remembered.until)}` : "reset unknown";
|
|
201
|
+
}
|
|
202
|
+
function timestamp(time) {
|
|
203
|
+
return new Date(time).toISOString().replace(/\.\d{3}Z$/u, "Z");
|
|
204
|
+
}
|
|
205
|
+
function formatMinutes(milliseconds) {
|
|
206
|
+
const minutes = Math.round(milliseconds / 6_000) / 10;
|
|
207
|
+
return `${minutes}-minute`;
|
|
208
|
+
}
|
|
209
|
+
// The timer stays referenced: a waiting run is working, and must keep its process alive.
|
|
210
|
+
function timerSleep(milliseconds) {
|
|
211
|
+
return new Promise((resolve) => { setTimeout(resolve, milliseconds); });
|
|
212
|
+
}
|
|
@@ -2,7 +2,9 @@ export function agentPrompt(request) {
|
|
|
2
2
|
return [
|
|
3
3
|
request.prompt,
|
|
4
4
|
"",
|
|
5
|
-
"Complete
|
|
5
|
+
"You are already executing one Agent state inside a Machine. Complete this assigned state using the available tools.",
|
|
6
|
+
"Do not re-delegate this assignment or start the parent workflow again unless the assignment explicitly requires bounded nested work.",
|
|
7
|
+
"Return evidence and an allowed outcome to the owning Machine; it controls verification, transitions, and Human decisions.",
|
|
6
8
|
`Allowed outcome types: ${request.outcomes.join(", ")}.`,
|
|
7
9
|
"When finished, end your final response with exactly one line in this form:",
|
|
8
10
|
'MACHINES_EVENT {"type":"completed"}',
|
package/dist/src/catalog.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface AgentPresetSummary {
|
|
|
14
14
|
readonly harness?: string;
|
|
15
15
|
readonly model?: string;
|
|
16
16
|
readonly thinking?: string;
|
|
17
|
+
/** The presets that take over at a usage limit, in order; absent when none is configured. */
|
|
18
|
+
readonly fallback?: readonly string[];
|
|
17
19
|
readonly source: string;
|
|
18
20
|
}
|
|
19
21
|
export interface LauncherLocation {
|
|
@@ -24,6 +26,7 @@ interface ResolvedAgentPresets {
|
|
|
24
26
|
readonly agents: Readonly<Record<string, AgentRunner>>;
|
|
25
27
|
readonly presets: Readonly<Record<string, AgentPreset>>;
|
|
26
28
|
readonly sources: Readonly<Record<string, string>>;
|
|
29
|
+
readonly fallback?: readonly string[];
|
|
27
30
|
}
|
|
28
31
|
export declare function inspectMachines(location?: LauncherLocation): Promise<readonly MachineSummary[]>;
|
|
29
32
|
export declare function inspectAgentPresets(location?: LauncherLocation): Promise<readonly AgentPresetSummary[]>;
|