@llblab/pi-actors 0.28.1 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/dist/lib/coordinator.js +67 -8
- package/dist/lib/paths.d.ts +1 -0
- package/dist/lib/paths.js +3 -0
- package/dist/lib/recipe-discovery.d.ts +1 -0
- package/dist/lib/recipe-discovery.js +11 -0
- package/dist/lib/tools.js +52 -5
- package/dist/recipes/pipeline-room-swarm.json +3 -1
- package/dist/skills/actors/SKILL.md +9 -4
- package/dist/skills/swarm/SKILL.md +3 -2
- package/docs/recipe-library.md +1 -1
- package/docs/tool-registry.md +1 -0
- package/lib/coordinator.ts +59 -10
- package/lib/paths.ts +4 -0
- package/lib/recipe-discovery.ts +12 -0
- package/lib/tools.ts +62 -6
- package/package.json +1 -1
- package/recipes/pipeline-room-swarm.json +3 -1
- package/skills/actors/SKILL.md +9 -4
- package/skills/swarm/SKILL.md +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.29.1: Subagent TTL Kill Hotfix
|
|
6
|
+
|
|
7
|
+
- `[Coordinator]` Added `subagent_ttl_ms` / `--subagent-ttl-ms` to the room-swarm adapter so timed-out subagent `pi -p` processes are terminated instead of only awaited.
|
|
8
|
+
|
|
9
|
+
## 0.29.0: Candidate Recipe Memory
|
|
10
|
+
|
|
11
|
+
- `[Spawn]` Capture inline spawn templates as non-registered candidate recipes under `~/.pi/agent/recipes/candidates`, making successful ad hoc actor patterns easy to replay by explicit path and promote manually.
|
|
12
|
+
- `[Inspect]` Add candidate recipe counts and verbose candidate metadata to recipe registry inspection without registering candidates as tools.
|
|
13
|
+
- `[Skills]` Documented the two-layer executable memory model: candidate recipes as a proving ground and root recipes as active tool memory.
|
|
14
|
+
|
|
5
15
|
## 0.28.1: Portable Agent Protocol Hotfix
|
|
6
16
|
|
|
7
17
|
- `[Docs]` Removed a machine-local private validation skill reference from the repository agent protocol so extension guidance stays portable.
|
package/dist/lib/coordinator.js
CHANGED
|
@@ -200,7 +200,25 @@ async function stopLocker(locker) {
|
|
|
200
200
|
locker.child.kill("SIGTERM");
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
|
-
function
|
|
203
|
+
function terminateProcessGroup(child, signal) {
|
|
204
|
+
if (!child.pid)
|
|
205
|
+
return;
|
|
206
|
+
try {
|
|
207
|
+
if (process.platform !== "win32")
|
|
208
|
+
process.kill(-child.pid, signal);
|
|
209
|
+
else
|
|
210
|
+
child.kill(signal);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
try {
|
|
214
|
+
child.kill(signal);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// Process already exited.
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function runPi(prompt, model, thinking, ttlMs = 0) {
|
|
204
222
|
return new Promise((resolve) => {
|
|
205
223
|
const args = [
|
|
206
224
|
"--tools",
|
|
@@ -216,17 +234,57 @@ function runPi(prompt, model, thinking) {
|
|
|
216
234
|
args.push("--thinking", thinking);
|
|
217
235
|
}
|
|
218
236
|
args.push("-p", prompt);
|
|
219
|
-
const child = spawn("pi", args, {
|
|
237
|
+
const child = spawn("pi", args, {
|
|
238
|
+
detached: process.platform !== "win32",
|
|
239
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
240
|
+
});
|
|
220
241
|
let stdout = "";
|
|
221
242
|
let stderr = "";
|
|
243
|
+
let settled = false;
|
|
244
|
+
let timedOut = false;
|
|
245
|
+
let ttlTimer;
|
|
246
|
+
let forceTimer;
|
|
247
|
+
const clearTimers = () => {
|
|
248
|
+
if (ttlTimer)
|
|
249
|
+
clearTimeout(ttlTimer);
|
|
250
|
+
if (forceTimer)
|
|
251
|
+
clearTimeout(forceTimer);
|
|
252
|
+
};
|
|
253
|
+
if (ttlMs > 0) {
|
|
254
|
+
ttlTimer = setTimeout(() => {
|
|
255
|
+
timedOut = true;
|
|
256
|
+
stderr += `\nSubagent TTL expired after ${ttlMs}ms; terminating process group.\n`;
|
|
257
|
+
terminateProcessGroup(child, "SIGTERM");
|
|
258
|
+
forceTimer = setTimeout(() => terminateProcessGroup(child, "SIGKILL"), 1000);
|
|
259
|
+
}, ttlMs);
|
|
260
|
+
}
|
|
222
261
|
child.stdout.on("data", (chunk) => {
|
|
223
262
|
stdout += chunk;
|
|
224
263
|
});
|
|
225
264
|
child.stderr.on("data", (chunk) => {
|
|
226
265
|
stderr += chunk;
|
|
227
266
|
});
|
|
228
|
-
child.on("close", (code) =>
|
|
229
|
-
|
|
267
|
+
child.on("close", (code, signal) => {
|
|
268
|
+
if (settled)
|
|
269
|
+
return;
|
|
270
|
+
settled = true;
|
|
271
|
+
clearTimers();
|
|
272
|
+
resolve({
|
|
273
|
+
code: timedOut ? 124 : (code ?? (signal ? 1 : 0)),
|
|
274
|
+
killed: timedOut,
|
|
275
|
+
signal,
|
|
276
|
+
stderr,
|
|
277
|
+
stdout,
|
|
278
|
+
timed_out: timedOut,
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
child.on("error", (error) => {
|
|
282
|
+
if (settled)
|
|
283
|
+
return;
|
|
284
|
+
settled = true;
|
|
285
|
+
clearTimers();
|
|
286
|
+
resolve({ code: 1, stdout, stderr: String(error) });
|
|
287
|
+
});
|
|
230
288
|
});
|
|
231
289
|
}
|
|
232
290
|
async function readRoomTranscript(config) {
|
|
@@ -268,7 +326,7 @@ async function synthesize(config, locker) {
|
|
|
268
326
|
}
|
|
269
327
|
const transcript = await readRoomTranscript(config);
|
|
270
328
|
const prompt = `Synthesize this transcript into a concise Markdown artifact. Mission: ${config.mission}. Include: Title, Consensus, Roles, Protocol, Final Artifact Shape, Next Actions, Open Questions. Use only the transcript evidence below.\n\nTRANSCRIPT:\n${transcript.slice(-24000)}`;
|
|
271
|
-
const result = await runPi(prompt, config.model, config.thinking);
|
|
329
|
+
const result = await runPi(prompt, config.model, config.thinking, config.subagentTtlMs);
|
|
272
330
|
const output = result.stdout.trim();
|
|
273
331
|
const diagnostics = config.stats
|
|
274
332
|
? `\n\n## Diagnostics\n\nparticipant_attempts=${config.stats.participantAttempts}\nparticipant_success=${config.stats.participantSuccess}\nparticipant_failures=${config.stats.participantFailures}\nsynthesis_code=${result.code}\ntranscript_messages=${transcript.trim() ? transcript.split("\n").length : 0}\n`
|
|
@@ -371,7 +429,7 @@ async function executeParticipantPrompt(role, basePrompt, config) {
|
|
|
371
429
|
"\nPlease acknowledge and address these direct messages in your response.\n";
|
|
372
430
|
finalPrompt += inboxSection;
|
|
373
431
|
}
|
|
374
|
-
const result = await runPi(finalPrompt, config.model, config.thinking);
|
|
432
|
+
const result = await runPi(finalPrompt, config.model, config.thinking, config.subagentTtlMs);
|
|
375
433
|
if (claimedIds.length > 0) {
|
|
376
434
|
const finalStatus = result.code === 0 ? "handled" : "failed";
|
|
377
435
|
await updateInboxMessagesStatus(config.runId, branchName, claimedIds, finalStatus);
|
|
@@ -399,13 +457,13 @@ async function participantJoin(role, config) {
|
|
|
399
457
|
const displayName = role.name;
|
|
400
458
|
const address = `branch:${config.runId}/${role.name}`;
|
|
401
459
|
const joinPrompt = `You are ${displayName}, ${role.persona}. Mission: ${config.mission}. Call tool message exactly once with to=${shellQuote(config.room)}, from=${shellQuote(address)}, type='actor.join', summary='${displayName} joined', body JSON {"role":${JSON.stringify(role.persona)},"display":${JSON.stringify(displayName)},"caps":["coordination","synthesis"],"claim":"coordinate on mission"}. Then print one short line.`;
|
|
402
|
-
await runPi(joinPrompt, config.model, config.thinking);
|
|
460
|
+
await runPi(joinPrompt, config.model, config.thinking, config.subagentTtlMs);
|
|
403
461
|
}
|
|
404
462
|
async function participantLeave(role, config) {
|
|
405
463
|
const displayName = role.name;
|
|
406
464
|
const address = `branch:${config.runId}/${role.name}`;
|
|
407
465
|
const leavePrompt = `Call tool message exactly once with to=${shellQuote(config.room)}, from=${shellQuote(address)}, type='actor.leave', summary='${displayName} left', body='finished coordinated work'. Then print goodbye.`;
|
|
408
|
-
await runPi(leavePrompt, config.model, config.thinking);
|
|
466
|
+
await runPi(leavePrompt, config.model, config.thinking, config.subagentTtlMs);
|
|
409
467
|
}
|
|
410
468
|
// 1. consensus / swarm mode: iterative chat in a room
|
|
411
469
|
async function runConsensus(config, locker) {
|
|
@@ -559,6 +617,7 @@ export async function runCoordinator(argv = process.argv.slice(2)) {
|
|
|
559
617
|
artifactPath: arg("artifact-path", ""),
|
|
560
618
|
locker: boolArg("locker", false),
|
|
561
619
|
lockerLeaseMs: numberArg("locker-lease-ms", 600000),
|
|
620
|
+
subagentTtlMs: numberArg("subagent-ttl-ms", 0),
|
|
562
621
|
stats: {
|
|
563
622
|
participantAttempts: 0,
|
|
564
623
|
participantSuccess: 0,
|
package/dist/lib/paths.d.ts
CHANGED
|
@@ -8,4 +8,5 @@ export declare function getConfigPath(agentDir?: string): string;
|
|
|
8
8
|
export declare function getExtensionTmpDir(agentDir?: string, extensionName?: string): string;
|
|
9
9
|
export declare function getRunStateRoot(agentDir?: string): string;
|
|
10
10
|
export declare function getRecipeRoot(agentDir?: string): string;
|
|
11
|
+
export declare function getRecipeCandidateRoot(agentDir?: string): string;
|
|
11
12
|
export declare function getPackagedRecipeRoot(): string;
|
package/dist/lib/paths.js
CHANGED
|
@@ -24,6 +24,9 @@ export function getRunStateRoot(agentDir = getAgentDir()) {
|
|
|
24
24
|
export function getRecipeRoot(agentDir = getAgentDir()) {
|
|
25
25
|
return join(agentDir, "recipes");
|
|
26
26
|
}
|
|
27
|
+
export function getRecipeCandidateRoot(agentDir = getAgentDir()) {
|
|
28
|
+
return join(getRecipeRoot(agentDir), "candidates");
|
|
29
|
+
}
|
|
27
30
|
export function getPackagedRecipeRoot() {
|
|
28
31
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
29
32
|
const compiledRoot = resolve(here, "..", "..", "recipes");
|
|
@@ -47,5 +47,6 @@ export declare function discoverRecipeSources(sources: RecipeDiscoverySource[]):
|
|
|
47
47
|
export declare function discoverRecipes(roots: string[]): RecipeDiscoveryResult;
|
|
48
48
|
export declare function createRecipeIntegrityManifest(result: RecipeDiscoveryResult): RecipeIntegrityManifestEntry[];
|
|
49
49
|
export declare function getShadowedLaunchDiagnostic(result: RecipeDiscoveryResult, id: string): Record<string, unknown> | undefined;
|
|
50
|
+
export declare function listCandidateRecipes(root: string): Array<Record<string, unknown>>;
|
|
50
51
|
export declare function summarizeDiscovery(result: RecipeDiscoveryResult): Record<string, unknown>;
|
|
51
52
|
export declare function toRegisteredTool(entry: DiscoveredRecipe): RegisteredTool | undefined;
|
|
@@ -426,6 +426,17 @@ export function getShadowedLaunchDiagnostic(result, id) {
|
|
|
426
426
|
reason: active.invalid ? "shadowed_invalid" : "shadowed_disabled",
|
|
427
427
|
};
|
|
428
428
|
}
|
|
429
|
+
export function listCandidateRecipes(root) {
|
|
430
|
+
return listRecipeFiles(root).map((path) => {
|
|
431
|
+
const id = RecipeReferences.getRecipeIdFromPath(path);
|
|
432
|
+
const config = RecipeReferences.readRawRecipeConfig(path);
|
|
433
|
+
return {
|
|
434
|
+
id,
|
|
435
|
+
path,
|
|
436
|
+
...(config?.description ? { description: config.description } : {}),
|
|
437
|
+
};
|
|
438
|
+
});
|
|
439
|
+
}
|
|
429
440
|
export function summarizeDiscovery(result) {
|
|
430
441
|
const recommendations = result.entries
|
|
431
442
|
.map((entry) => recommendationForEntry(entry, result.active.get(entry.id)?.path))
|
package/dist/lib/tools.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Owns generated runtime tool schemas and the register_tool management tool schema
|
|
5
5
|
*/
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { dirname, join } from "node:path";
|
|
9
9
|
import * as ActorMessages from "./actor-messages.js";
|
|
10
10
|
import * as ActorRooms from "./actor-rooms.js";
|
|
@@ -144,6 +144,8 @@ function compactAsyncRunStatus(value) {
|
|
|
144
144
|
tokens.push(`code=${String(result.code)}`);
|
|
145
145
|
if (result.killed === true)
|
|
146
146
|
tokens.push("killed=true");
|
|
147
|
+
if (status.candidate_recipe)
|
|
148
|
+
tokens.push(`candidate_recipe=${String(status.candidate_recipe)}`);
|
|
147
149
|
return `\n${tokens.join(" ")}`;
|
|
148
150
|
}
|
|
149
151
|
function compactRunMessages(messages) {
|
|
@@ -474,10 +476,13 @@ function compactRecipeRegistry(summary) {
|
|
|
474
476
|
const diagnostics = Array.isArray(summary.diagnostics)
|
|
475
477
|
? summary.diagnostics.length
|
|
476
478
|
: 0;
|
|
479
|
+
const candidates = Array.isArray(summary.candidates)
|
|
480
|
+
? summary.candidates.length
|
|
481
|
+
: 0;
|
|
477
482
|
const recommendations = Array.isArray(summary.recommendations)
|
|
478
483
|
? summary.recommendations.length
|
|
479
484
|
: 0;
|
|
480
|
-
return `\nrecipes active=${active} shadowed=${shadowed} invalid=${invalid} disabled=${disabled} recommendations=${recommendations} diagnostics=${diagnostics}`;
|
|
485
|
+
return `\nrecipes active=${active} candidates=${candidates} shadowed=${shadowed} invalid=${invalid} disabled=${disabled} recommendations=${recommendations} diagnostics=${diagnostics}`;
|
|
481
486
|
}
|
|
482
487
|
function compactActorMessageResult(message, result) {
|
|
483
488
|
const tokens = [
|
|
@@ -581,6 +586,40 @@ function shadowedRecipeLaunchDiagnostic(recipe) {
|
|
|
581
586
|
]);
|
|
582
587
|
return RecipeDiscovery.getShadowedLaunchDiagnostic(discovery, recipe);
|
|
583
588
|
}
|
|
589
|
+
function candidateRecipeName(run) {
|
|
590
|
+
return `${run.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "spawn"}.json`;
|
|
591
|
+
}
|
|
592
|
+
function candidateRecipeDefaults(values) {
|
|
593
|
+
const ignored = new Set([
|
|
594
|
+
"actor_address",
|
|
595
|
+
"communication_file",
|
|
596
|
+
"default_room",
|
|
597
|
+
"run_id",
|
|
598
|
+
"state_dir",
|
|
599
|
+
]);
|
|
600
|
+
const defaults = Object.fromEntries(Object.entries(values).filter(([key]) => !ignored.has(key)));
|
|
601
|
+
return Object.keys(defaults).length > 0 ? defaults : undefined;
|
|
602
|
+
}
|
|
603
|
+
function writeSpawnCandidateRecipe(input, meta) {
|
|
604
|
+
if (process.env.NODE_TEST_CONTEXT &&
|
|
605
|
+
process.env.PI_ACTORS_ENABLE_SPAWN_CANDIDATES_IN_TEST !== "1")
|
|
606
|
+
return undefined;
|
|
607
|
+
if (input.template === undefined || input.file !== undefined || input.recipe !== undefined)
|
|
608
|
+
return undefined;
|
|
609
|
+
const root = Paths.getRecipeCandidateRoot();
|
|
610
|
+
mkdirSync(root, { recursive: true });
|
|
611
|
+
const path = join(root, candidateRecipeName(String(meta.run)));
|
|
612
|
+
const defaults = candidateRecipeDefaults(meta.values);
|
|
613
|
+
const recipe = {
|
|
614
|
+
async: true,
|
|
615
|
+
description: `Candidate recipe captured from spawn run ${String(meta.run)}`,
|
|
616
|
+
...(meta.artifacts ? { artifacts: meta.artifacts } : {}),
|
|
617
|
+
...(defaults ? { defaults } : {}),
|
|
618
|
+
template: input.template,
|
|
619
|
+
};
|
|
620
|
+
writeFileSync(path, `${JSON.stringify(recipe, null, 2)}\n`, { flag: "wx" });
|
|
621
|
+
return path;
|
|
622
|
+
}
|
|
584
623
|
function enhanceSpawnRecipeError(error, recipe) {
|
|
585
624
|
const diagnostic = shadowedRecipeLaunchDiagnostic(recipe);
|
|
586
625
|
if (!diagnostic)
|
|
@@ -704,16 +743,20 @@ export function createSpawnToolDefinition() {
|
|
|
704
743
|
catch (error) {
|
|
705
744
|
throw enhanceSpawnRecipeError(error, recipe);
|
|
706
745
|
}
|
|
746
|
+
const candidateRecipe = writeSpawnCandidateRecipe(input, meta);
|
|
747
|
+
const details = candidateRecipe
|
|
748
|
+
? { ...meta, candidate_recipe: candidateRecipe }
|
|
749
|
+
: meta;
|
|
707
750
|
ActorRooms.ensureDefaultRoom(meta.state_dir, String(meta.run));
|
|
708
751
|
ActorRooms.writeCommunicationSnapshot(meta.state_dir, String(meta.run));
|
|
709
752
|
return {
|
|
710
753
|
content: [
|
|
711
754
|
{
|
|
712
755
|
type: "text",
|
|
713
|
-
text: maybeJsonText(
|
|
756
|
+
text: maybeJsonText(details, input.verbose === true, compactAsyncRunStatus(details)),
|
|
714
757
|
},
|
|
715
758
|
],
|
|
716
|
-
details
|
|
759
|
+
details,
|
|
717
760
|
};
|
|
718
761
|
},
|
|
719
762
|
};
|
|
@@ -773,7 +816,11 @@ export function createInspectToolDefinition(deps = {}) {
|
|
|
773
816
|
},
|
|
774
817
|
{ root: deps.packagedRecipeRoot ?? Paths.getPackagedRecipeRoot() },
|
|
775
818
|
]);
|
|
776
|
-
const
|
|
819
|
+
const recipeRoot = deps.recipeRoot ?? Paths.getRecipeRoot();
|
|
820
|
+
const summary = {
|
|
821
|
+
...RecipeDiscovery.summarizeDiscovery(discovered),
|
|
822
|
+
candidates: RecipeDiscovery.listCandidateRecipes(join(recipeRoot, "candidates")),
|
|
823
|
+
};
|
|
777
824
|
return {
|
|
778
825
|
content: [
|
|
779
826
|
{
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"delay:int",
|
|
12
12
|
"locker:bool",
|
|
13
13
|
"locker_lease_ms:int",
|
|
14
|
+
"subagent_ttl_ms:int",
|
|
14
15
|
"artifact_path:path",
|
|
15
16
|
"repo:path"
|
|
16
17
|
],
|
|
@@ -23,6 +24,7 @@
|
|
|
23
24
|
"delay": "10",
|
|
24
25
|
"locker": "false",
|
|
25
26
|
"locker_lease_ms": "600000",
|
|
27
|
+
"subagent_ttl_ms": "0",
|
|
26
28
|
"artifact_path": "{state_dir}/room-swarm-artifact.md",
|
|
27
29
|
"repo": "~/.pi/agent/extensions/pi-actors"
|
|
28
30
|
},
|
|
@@ -44,5 +46,5 @@
|
|
|
44
46
|
"run.failed"
|
|
45
47
|
]
|
|
46
48
|
},
|
|
47
|
-
"template": "{repo}/scripts/coordinator.mjs --run-id={run_id} --mode={mode} --mission={mission} --model={model} --thinking={thinking} --roles={roles} --roles-path={roles_path} --rounds={rounds} --delay={delay} --locker={locker} --locker-lease-ms={locker_lease_ms} --artifact-path={artifact_path}"
|
|
49
|
+
"template": "{repo}/scripts/coordinator.mjs --run-id={run_id} --mode={mode} --mission={mission} --model={model} --thinking={thinking} --roles={roles} --roles-path={roles_path} --rounds={rounds} --delay={delay} --locker={locker} --locker-lease-ms={locker_lease_ms} --subagent-ttl-ms={subagent_ttl_ms} --artifact-path={artifact_path}"
|
|
48
50
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: actors
|
|
3
3
|
description: Highest-density practical guide for pi-actors. Read this skill whenever prompt and tools are not enough for spawn, message, inspect, actor runs, tools, recipes, command templates, async lifecycle, mailboxes, artifacts, and local orchestration mechanics.
|
|
4
4
|
metadata:
|
|
5
|
-
version: 0.
|
|
5
|
+
version: 0.29.1
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Actors (pi-actors)
|
|
@@ -235,11 +235,16 @@ Priority for same-id recipes:
|
|
|
235
235
|
|
|
236
236
|
Only matching filename ids compete. Higher priority shadows lower priority; within one priority layer, same-id JSON shadows Markdown. An invalid or `disabled: true` higher-priority recipe blocks fallback so the agent does not silently run standard-library behavior when a user override is broken or intentionally disabled.
|
|
237
237
|
|
|
238
|
-
Muscle-memory lens:
|
|
238
|
+
Muscle-memory lens: pi-actors has two durable executable-memory layers.
|
|
239
|
+
|
|
240
|
+
1. `~/.pi/agent/recipes/*.json` and `*.md` are the agent's active capability memory. Every recipe in that directory becomes an easy-to-call tool automatically and survives into later sessions. Descriptions matter here because they become the tool's operator-facing title/context.
|
|
241
|
+
2. `~/.pi/agent/recipes/candidates/*.json` is candidate memory captured from successful inline `spawn template=...` runs. Candidates are not registered tools and do not enter the injected tool surface. They remain reusable by explicit path, e.g. `spawn file="~/.pi/agent/recipes/candidates/<name>.json"`, and can be promoted by moving or copying one level up into `~/.pi/agent/recipes`.
|
|
242
|
+
|
|
243
|
+
Agents grow active memory by calling `register_tool` or by deliberate recipe-file edits. They grow candidate memory by trying ad hoc actors successfully. Treat both as executable habits: candidates are the workbench/proving ground; root recipes are promoted muscle memory.
|
|
239
244
|
|
|
240
245
|
Usage lens: user recipes may carry extension-maintained launch metadata such as `usage.calls` and `usage.last_called`. The extension increments the counter when it starts that concrete recipe; agents should not hand-edit counters as part of normal recipe maintenance. Treat usage as evidence for usefulness analysis: heavily used recipes are good candidates for promotion, documentation, or stronger tests; unused recipes are cleanup candidates. Do not use failure counts as a primary usefulness signal because failures may reflect bad caller judgment rather than bad recipes. Do not delete or demote solely from counters without operator approval.
|
|
241
246
|
|
|
242
|
-
Promotion lens: successful transient/ad hoc actor runs are evidence, not commands. If
|
|
247
|
+
Promotion lens: successful transient/ad hoc actor runs are evidence, not commands. Inline spawns leave candidate recipes as replayable evidence, not active tools. If a candidate is repeatable, parameterized, safe enough, and likely useful later, the agent may promote it by moving/copying it into `~/.pi/agent/recipes` or by calling `register_tool` with a concise name, typed args/defaults, and a reviewed template or recipe path. Do not auto-register every success; do not promote temp paths, secrets, one-off prompts, or project-private assumptions without normalization and approval.
|
|
243
248
|
|
|
244
249
|
Cleanup rule: periodically inspect `~/.pi/agent/recipes` as the live muscle-memory set. For each stale, duplicate, too-specific, or low-value recipe, choose one explicit action: keep as a tool, move it out of the agent recipe root to retain recipe-only memory, merge into a better recipe, or delete/archive the file. Prefer moving over deletion when the recipe may still be useful as a component. Never silently remove tools during unrelated work.
|
|
245
250
|
|
|
@@ -297,7 +302,7 @@ Use packaged recipes by name with `spawn file=<name>` for async actors, or regis
|
|
|
297
302
|
- [`pipeline-docs-maintenance`](../../recipes/pipeline-docs-maintenance.json): docs index/review/planning → maintenance artifact.
|
|
298
303
|
- Artifacts: [`pipeline-artifact-report`](../../recipes/pipeline-artifact-report.json), [`pipeline-artifact-write`](../../recipes/pipeline-artifact-write.json), [`pipeline-artifact-bundle`](../../recipes/pipeline-artifact-bundle.json).
|
|
299
304
|
- Review gates: [`pipeline-quorum-review`](../../recipes/pipeline-quorum-review.json), [`pipeline-review-readiness`](../../recipes/pipeline-review-readiness.json).
|
|
300
|
-
- Task-first workflows: [`pipeline-architect-coordinator`](../../recipes/pipeline-architect-coordinator.json), [`pipeline-research-synthesis`](../../recipes/pipeline-research-synthesis.json), [`pipeline-development-tasking`](../../recipes/pipeline-development-tasking.json), [`pipeline-checkpoint-continuation`](../../recipes/pipeline-checkpoint-continuation.json), [`pipeline-media-library`](../../recipes/pipeline-media-library.json), [`pipeline-room-swarm`](../../recipes/pipeline-room-swarm.json). For room swarms, choose `mode` from `consensus`, `pipeline`, `fanout`, or `pool`; prefer `roles_path` for custom role JSON and keep role `name` ASCII-safe for branch addresses. Use `locker=true` when the swarm needs a coordinator-locker-backed artifact lock and journal.
|
|
305
|
+
- Task-first workflows: [`pipeline-architect-coordinator`](../../recipes/pipeline-architect-coordinator.json), [`pipeline-research-synthesis`](../../recipes/pipeline-research-synthesis.json), [`pipeline-development-tasking`](../../recipes/pipeline-development-tasking.json), [`pipeline-checkpoint-continuation`](../../recipes/pipeline-checkpoint-continuation.json), [`pipeline-media-library`](../../recipes/pipeline-media-library.json), [`pipeline-room-swarm`](../../recipes/pipeline-room-swarm.json). For room swarms, choose `mode` from `consensus`, `pipeline`, `fanout`, or `pool`; prefer `roles_path` for custom role JSON and keep role `name` ASCII-safe for branch addresses. Set `subagent_ttl_ms` when participant processes need a hard kill budget. Use `locker=true` when the swarm needs a coordinator-locker-backed artifact lock and journal.
|
|
301
306
|
|
|
302
307
|
### Utilities
|
|
303
308
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: swarm
|
|
3
3
|
description: Subagent orchestration with scoped locks and quorum consensus. Use for multi-model review, parallel scoped work, delegated audit, and coordinated subagent execution.
|
|
4
4
|
metadata:
|
|
5
|
-
version: 0.
|
|
5
|
+
version: 0.29.1
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Swarm
|
|
@@ -30,7 +30,8 @@ Maintain this skill as a living orchestration standard. When real swarm work exp
|
|
|
30
30
|
- `Async Run`: A local lifecycle envelope around a command-template swarm composer or utility. It owns state, logs, status, cancellation, and observability, not swarm semantics.
|
|
31
31
|
- `Lens`: A deliberately narrow cognitive role assigned to one subagent, such as security, tests, architecture, economics, or operator UX.
|
|
32
32
|
- `Task Card`: A bounded implementation assignment with goal, allowed files, avoided files, expected output, and validation gates.
|
|
33
|
-
- `Component Capability`: An abstract adapter operation such as launcher, reviewer, verifier, merger, quorum, checkpoint, follow-up, judge, or normalizer. Swarm may target these capabilities, but local adapters bind them to concrete tools, recipes, command templates, async runs, or services.
|
|
33
|
+
- `Component Capability`: An abstract adapter operation such as launcher, reviewer, verifier, merger, quorum, checkpoint, follow-up, judge, or normalizer. Swarm may target these capabilities, but local adapters bind them to concrete tools, recipes, candidate recipes, command templates, async runs, or services.
|
|
34
|
+
- `Candidate Recipe`: A reusable but non-registered recipe captured from a successful inline actor spawn. It can be replayed by explicit file path and later promoted into the active tool recipe root after enough dogfood.
|
|
34
35
|
- `Coordinator Checkpoint`: A deliberate subagent pause where the subagent preserves its working context, sends a bounded question or status to the orchestrator, receives a coordinator reply, and continues in the same subagent context.
|
|
35
36
|
- `Evidence Checkpoint`: A deliberate stop where a subagent records sources, assumptions, confidence, contradictions, or blocking evidence gaps before synthesis.
|
|
36
37
|
- `Integrator`: The human or agent that merges isolated branches/worktrees into the shared target and owns conflict resolution.
|
package/docs/recipe-library.md
CHANGED
|
@@ -84,7 +84,7 @@ Pipeline recipes demonstrate second-order composition:
|
|
|
84
84
|
- `recipes/pipeline-development-tasking.json`: Plan → task card → critique → integrator handoff.
|
|
85
85
|
- `recipes/pipeline-docs-maintenance.json`: Docs index → documentation review → maintenance plan → artifact report.
|
|
86
86
|
- `recipes/pipeline-media-library.json`: Playlist build → media-library artifact report.
|
|
87
|
-
- `recipes/pipeline-room-swarm.json`: Room participants join `room:<run>`, coordinate over repeated room-visible rounds, leave cleanly, and synthesize the room transcript into a caller-provided artifact path. Supported coordinator modes are `consensus`, `pipeline`, `fanout`, and `pool`; unknown modes fail closed instead of silently running consensus. Keep model/thinking/mission policy caller-owned. Custom roles can be supplied with `roles_path` as a JSON array of `{ "name", "persona" }` objects; `name` stays ASCII-safe for `branch:<run>/<name>` addresses and debugger output remains plain and name-driven. The packaged swarm uses contacts for peer awareness but does not rely on direct branch delivery unless a caller-specific worker protocol consumes branch envelopes. Set `locker=true` to compose a local `coordinator-locker` cell under `{state_dir}/locker` for artifact ownership, resource lease locks, and a decision journal without merging locker policy into the room-participant script.
|
|
87
|
+
- `recipes/pipeline-room-swarm.json`: Room participants join `room:<run>`, coordinate over repeated room-visible rounds, leave cleanly, and synthesize the room transcript into a caller-provided artifact path. Supported coordinator modes are `consensus`, `pipeline`, `fanout`, and `pool`; unknown modes fail closed instead of silently running consensus. Keep model/thinking/mission policy caller-owned. Custom roles can be supplied with `roles_path` as a JSON array of `{ "name", "persona" }` objects; `name` stays ASCII-safe for `branch:<run>/<name>` addresses and debugger output remains plain and name-driven. The packaged swarm uses contacts for peer awareness but does not rely on direct branch delivery unless a caller-specific worker protocol consumes branch envelopes. Set `subagent_ttl_ms` to a positive millisecond budget when participant `pi -p` processes must be killed instead of awaited indefinitely. Set `locker=true` to compose a local `coordinator-locker` cell under `{state_dir}/locker` for artifact ownership, resource lease locks, and a decision journal without merging locker policy into the room-participant script.
|
|
88
88
|
- `recipes/pipeline-artifact-report.json`: Normalize → artifact-shaped output → actor-message-shaped record. This pipeline prepares a candidate artifact and emits `artifact.prepared`/`artifact.blocked`; the `artifact_path` is a target path, not a guarantee that the file was written.
|
|
89
89
|
- `recipes/pipeline-artifact-write.json`: Normalize → artifact-shaped output → deterministic artifact write → actor-message-shaped record. Use only when the caller explicitly wants filesystem writes; `write_mode` is `create`, `overwrite`, or `append`.
|
|
90
90
|
- `recipes/pipeline-artifact-bundle.json`: Optional validation → deterministic artifact write → machine-readable manifest generation → deterministic manifest write → actor-message-shaped record. Use when the caller explicitly wants a filesystem handoff bundle with both artifact and manifest paths.
|
package/docs/tool-registry.md
CHANGED
|
@@ -10,6 +10,7 @@ The registry source is location-discovered recipes, not a live tool-only JSON fi
|
|
|
10
10
|
|
|
11
11
|
- `~/.pi/agent/recipes/*.json` and `*.md` are the highest-priority user recipe root and the operator-managed tool set.
|
|
12
12
|
- Recipes in that root are tools by location.
|
|
13
|
+
- `~/.pi/agent/recipes/candidates/*.json` are captured inline-spawn candidates, not registered tools; promote one by moving or copying it up one level into `~/.pi/agent/recipes`. `inspect target=recipes view=summary` reports their count, and verbose output lists their paths/descriptions for explicit replay by file path.
|
|
13
14
|
- Packaged pi-actors recipes are the lower-priority standard library of declarative actor config components, not automatically registered tools.
|
|
14
15
|
- Ad hoc recipe files outside the user recipe root are components unless explicitly registered/copied into `~/.pi/agent/recipes`.
|
|
15
16
|
- Recipe identity is the filename basename; `~/.pi/agent/recipes/docs_review.json` and `docs_review.md` both have id/tool name `docs_review`.
|
package/lib/coordinator.ts
CHANGED
|
@@ -220,7 +220,21 @@ async function stopLocker(locker) {
|
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
function
|
|
223
|
+
function terminateProcessGroup(child, signal) {
|
|
224
|
+
if (!child.pid) return;
|
|
225
|
+
try {
|
|
226
|
+
if (process.platform !== "win32") process.kill(-child.pid, signal);
|
|
227
|
+
else child.kill(signal);
|
|
228
|
+
} catch {
|
|
229
|
+
try {
|
|
230
|
+
child.kill(signal);
|
|
231
|
+
} catch {
|
|
232
|
+
// Process already exited.
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function runPi(prompt, model, thinking, ttlMs = 0) {
|
|
224
238
|
return new Promise((resolve) => {
|
|
225
239
|
const args = [
|
|
226
240
|
"--tools",
|
|
@@ -237,19 +251,53 @@ function runPi(prompt, model, thinking) {
|
|
|
237
251
|
}
|
|
238
252
|
args.push("-p", prompt);
|
|
239
253
|
|
|
240
|
-
const child = spawn("pi", args, {
|
|
254
|
+
const child = spawn("pi", args, {
|
|
255
|
+
detached: process.platform !== "win32",
|
|
256
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
257
|
+
});
|
|
241
258
|
let stdout = "";
|
|
242
259
|
let stderr = "";
|
|
260
|
+
let settled = false;
|
|
261
|
+
let timedOut = false;
|
|
262
|
+
let ttlTimer;
|
|
263
|
+
let forceTimer;
|
|
264
|
+
const clearTimers = () => {
|
|
265
|
+
if (ttlTimer) clearTimeout(ttlTimer);
|
|
266
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
267
|
+
};
|
|
268
|
+
if (ttlMs > 0) {
|
|
269
|
+
ttlTimer = setTimeout(() => {
|
|
270
|
+
timedOut = true;
|
|
271
|
+
stderr += `\nSubagent TTL expired after ${ttlMs}ms; terminating process group.\n`;
|
|
272
|
+
terminateProcessGroup(child, "SIGTERM");
|
|
273
|
+
forceTimer = setTimeout(() => terminateProcessGroup(child, "SIGKILL"), 1000);
|
|
274
|
+
}, ttlMs);
|
|
275
|
+
}
|
|
243
276
|
child.stdout.on("data", (chunk) => {
|
|
244
277
|
stdout += chunk;
|
|
245
278
|
});
|
|
246
279
|
child.stderr.on("data", (chunk) => {
|
|
247
280
|
stderr += chunk;
|
|
248
281
|
});
|
|
249
|
-
child.on("close", (code) =>
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
282
|
+
child.on("close", (code, signal) => {
|
|
283
|
+
if (settled) return;
|
|
284
|
+
settled = true;
|
|
285
|
+
clearTimers();
|
|
286
|
+
resolve({
|
|
287
|
+
code: timedOut ? 124 : (code ?? (signal ? 1 : 0)),
|
|
288
|
+
killed: timedOut,
|
|
289
|
+
signal,
|
|
290
|
+
stderr,
|
|
291
|
+
stdout,
|
|
292
|
+
timed_out: timedOut,
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
child.on("error", (error) => {
|
|
296
|
+
if (settled) return;
|
|
297
|
+
settled = true;
|
|
298
|
+
clearTimers();
|
|
299
|
+
resolve({ code: 1, stdout, stderr: String(error) });
|
|
300
|
+
});
|
|
253
301
|
});
|
|
254
302
|
}
|
|
255
303
|
|
|
@@ -294,7 +342,7 @@ async function synthesize(config, locker) {
|
|
|
294
342
|
}
|
|
295
343
|
const transcript = await readRoomTranscript(config);
|
|
296
344
|
const prompt = `Synthesize this transcript into a concise Markdown artifact. Mission: ${config.mission}. Include: Title, Consensus, Roles, Protocol, Final Artifact Shape, Next Actions, Open Questions. Use only the transcript evidence below.\n\nTRANSCRIPT:\n${transcript.slice(-24000)}`;
|
|
297
|
-
const result = await runPi(prompt, config.model, config.thinking);
|
|
345
|
+
const result = await runPi(prompt, config.model, config.thinking, config.subagentTtlMs);
|
|
298
346
|
const output = result.stdout.trim();
|
|
299
347
|
const diagnostics = config.stats
|
|
300
348
|
? `\n\n## Diagnostics\n\nparticipant_attempts=${config.stats.participantAttempts}\nparticipant_success=${config.stats.participantSuccess}\nparticipant_failures=${config.stats.participantFailures}\nsynthesis_code=${result.code}\ntranscript_messages=${transcript.trim() ? transcript.split("\n").length : 0}\n`
|
|
@@ -417,7 +465,7 @@ async function executeParticipantPrompt(role, basePrompt, config) {
|
|
|
417
465
|
finalPrompt += inboxSection;
|
|
418
466
|
}
|
|
419
467
|
|
|
420
|
-
const result = await runPi(finalPrompt, config.model, config.thinking);
|
|
468
|
+
const result = await runPi(finalPrompt, config.model, config.thinking, config.subagentTtlMs);
|
|
421
469
|
|
|
422
470
|
if (claimedIds.length > 0) {
|
|
423
471
|
const finalStatus = result.code === 0 ? "handled" : "failed";
|
|
@@ -454,14 +502,14 @@ async function participantJoin(role, config) {
|
|
|
454
502
|
const displayName = role.name;
|
|
455
503
|
const address = `branch:${config.runId}/${role.name}`;
|
|
456
504
|
const joinPrompt = `You are ${displayName}, ${role.persona}. Mission: ${config.mission}. Call tool message exactly once with to=${shellQuote(config.room)}, from=${shellQuote(address)}, type='actor.join', summary='${displayName} joined', body JSON {"role":${JSON.stringify(role.persona)},"display":${JSON.stringify(displayName)},"caps":["coordination","synthesis"],"claim":"coordinate on mission"}. Then print one short line.`;
|
|
457
|
-
await runPi(joinPrompt, config.model, config.thinking);
|
|
505
|
+
await runPi(joinPrompt, config.model, config.thinking, config.subagentTtlMs);
|
|
458
506
|
}
|
|
459
507
|
|
|
460
508
|
async function participantLeave(role, config) {
|
|
461
509
|
const displayName = role.name;
|
|
462
510
|
const address = `branch:${config.runId}/${role.name}`;
|
|
463
511
|
const leavePrompt = `Call tool message exactly once with to=${shellQuote(config.room)}, from=${shellQuote(address)}, type='actor.leave', summary='${displayName} left', body='finished coordinated work'. Then print goodbye.`;
|
|
464
|
-
await runPi(leavePrompt, config.model, config.thinking);
|
|
512
|
+
await runPi(leavePrompt, config.model, config.thinking, config.subagentTtlMs);
|
|
465
513
|
}
|
|
466
514
|
|
|
467
515
|
// 1. consensus / swarm mode: iterative chat in a room
|
|
@@ -646,6 +694,7 @@ const config = {
|
|
|
646
694
|
artifactPath: arg("artifact-path", ""),
|
|
647
695
|
locker: boolArg("locker", false),
|
|
648
696
|
lockerLeaseMs: numberArg("locker-lease-ms", 600000),
|
|
697
|
+
subagentTtlMs: numberArg("subagent-ttl-ms", 0),
|
|
649
698
|
stats: {
|
|
650
699
|
participantAttempts: 0,
|
|
651
700
|
participantSuccess: 0,
|
package/lib/paths.ts
CHANGED
|
@@ -36,6 +36,10 @@ export function getRecipeRoot(agentDir = getAgentDir()): string {
|
|
|
36
36
|
return join(agentDir, "recipes");
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
export function getRecipeCandidateRoot(agentDir = getAgentDir()): string {
|
|
40
|
+
return join(getRecipeRoot(agentDir), "candidates");
|
|
41
|
+
}
|
|
42
|
+
|
|
39
43
|
export function getPackagedRecipeRoot(): string {
|
|
40
44
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
41
45
|
const compiledRoot = resolve(here, "..", "..", "recipes");
|
package/lib/recipe-discovery.ts
CHANGED
|
@@ -575,6 +575,18 @@ export function getShadowedLaunchDiagnostic(
|
|
|
575
575
|
};
|
|
576
576
|
}
|
|
577
577
|
|
|
578
|
+
export function listCandidateRecipes(root: string): Array<Record<string, unknown>> {
|
|
579
|
+
return listRecipeFiles(root).map((path) => {
|
|
580
|
+
const id = RecipeReferences.getRecipeIdFromPath(path);
|
|
581
|
+
const config = RecipeReferences.readRawRecipeConfig(path);
|
|
582
|
+
return {
|
|
583
|
+
id,
|
|
584
|
+
path,
|
|
585
|
+
...(config?.description ? { description: config.description } : {}),
|
|
586
|
+
};
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
578
590
|
export function summarizeDiscovery(
|
|
579
591
|
result: RecipeDiscoveryResult,
|
|
580
592
|
): Record<string, unknown> {
|
package/lib/tools.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
10
|
|
|
11
11
|
import * as ActorMessages from "./actor-messages.ts";
|
|
@@ -183,6 +183,7 @@ function compactAsyncRunStatus(value: unknown): string {
|
|
|
183
183
|
tokens.push(`failures=${failures}`);
|
|
184
184
|
if (result.code !== undefined) tokens.push(`code=${String(result.code)}`);
|
|
185
185
|
if (result.killed === true) tokens.push("killed=true");
|
|
186
|
+
if (status.candidate_recipe) tokens.push(`candidate_recipe=${String(status.candidate_recipe)}`);
|
|
186
187
|
return `\n${tokens.join(" ")}`;
|
|
187
188
|
}
|
|
188
189
|
|
|
@@ -574,10 +575,13 @@ function compactRecipeRegistry(summary: Record<string, unknown>): string {
|
|
|
574
575
|
const diagnostics = Array.isArray(summary.diagnostics)
|
|
575
576
|
? summary.diagnostics.length
|
|
576
577
|
: 0;
|
|
578
|
+
const candidates = Array.isArray(summary.candidates)
|
|
579
|
+
? summary.candidates.length
|
|
580
|
+
: 0;
|
|
577
581
|
const recommendations = Array.isArray(summary.recommendations)
|
|
578
582
|
? summary.recommendations.length
|
|
579
583
|
: 0;
|
|
580
|
-
return `\nrecipes active=${active} shadowed=${shadowed} invalid=${invalid} disabled=${disabled} recommendations=${recommendations} diagnostics=${diagnostics}`;
|
|
584
|
+
return `\nrecipes active=${active} candidates=${candidates} shadowed=${shadowed} invalid=${invalid} disabled=${disabled} recommendations=${recommendations} diagnostics=${diagnostics}`;
|
|
581
585
|
}
|
|
582
586
|
|
|
583
587
|
function compactActorMessageResult(
|
|
@@ -722,6 +726,50 @@ function shadowedRecipeLaunchDiagnostic(
|
|
|
722
726
|
return RecipeDiscovery.getShadowedLaunchDiagnostic(discovery, recipe);
|
|
723
727
|
}
|
|
724
728
|
|
|
729
|
+
function candidateRecipeName(run: string): string {
|
|
730
|
+
return `${run.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "spawn"}.json`;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function candidateRecipeDefaults(values: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
734
|
+
const ignored = new Set([
|
|
735
|
+
"actor_address",
|
|
736
|
+
"communication_file",
|
|
737
|
+
"default_room",
|
|
738
|
+
"run_id",
|
|
739
|
+
"state_dir",
|
|
740
|
+
]);
|
|
741
|
+
const defaults = Object.fromEntries(
|
|
742
|
+
Object.entries(values).filter(([key]) => !ignored.has(key)),
|
|
743
|
+
);
|
|
744
|
+
return Object.keys(defaults).length > 0 ? defaults : undefined;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function writeSpawnCandidateRecipe(
|
|
748
|
+
input: Record<string, unknown>,
|
|
749
|
+
meta: AsyncRuns.AsyncRunMeta,
|
|
750
|
+
): string | undefined {
|
|
751
|
+
if (
|
|
752
|
+
process.env.NODE_TEST_CONTEXT &&
|
|
753
|
+
process.env.PI_ACTORS_ENABLE_SPAWN_CANDIDATES_IN_TEST !== "1"
|
|
754
|
+
)
|
|
755
|
+
return undefined;
|
|
756
|
+
if (input.template === undefined || input.file !== undefined || input.recipe !== undefined)
|
|
757
|
+
return undefined;
|
|
758
|
+
const root = Paths.getRecipeCandidateRoot();
|
|
759
|
+
mkdirSync(root, { recursive: true });
|
|
760
|
+
const path = join(root, candidateRecipeName(String(meta.run)));
|
|
761
|
+
const defaults = candidateRecipeDefaults(meta.values);
|
|
762
|
+
const recipe = {
|
|
763
|
+
async: true,
|
|
764
|
+
description: `Candidate recipe captured from spawn run ${String(meta.run)}`,
|
|
765
|
+
...(meta.artifacts ? { artifacts: meta.artifacts } : {}),
|
|
766
|
+
...(defaults ? { defaults } : {}),
|
|
767
|
+
template: input.template,
|
|
768
|
+
};
|
|
769
|
+
writeFileSync(path, `${JSON.stringify(recipe, null, 2)}\n`, { flag: "wx" });
|
|
770
|
+
return path;
|
|
771
|
+
}
|
|
772
|
+
|
|
725
773
|
function enhanceSpawnRecipeError(error: unknown, recipe: unknown): Error {
|
|
726
774
|
const diagnostic = shadowedRecipeLaunchDiagnostic(recipe);
|
|
727
775
|
if (!diagnostic) return error instanceof Error ? error : new Error(String(error));
|
|
@@ -911,6 +959,10 @@ export function createSpawnToolDefinition<
|
|
|
911
959
|
} catch (error) {
|
|
912
960
|
throw enhanceSpawnRecipeError(error, recipe);
|
|
913
961
|
}
|
|
962
|
+
const candidateRecipe = writeSpawnCandidateRecipe(input, meta);
|
|
963
|
+
const details = candidateRecipe
|
|
964
|
+
? { ...meta, candidate_recipe: candidateRecipe }
|
|
965
|
+
: meta;
|
|
914
966
|
ActorRooms.ensureDefaultRoom(meta.state_dir, String(meta.run));
|
|
915
967
|
ActorRooms.writeCommunicationSnapshot(meta.state_dir, String(meta.run));
|
|
916
968
|
return {
|
|
@@ -918,13 +970,13 @@ export function createSpawnToolDefinition<
|
|
|
918
970
|
{
|
|
919
971
|
type: "text" as const,
|
|
920
972
|
text: maybeJsonText(
|
|
921
|
-
|
|
973
|
+
details,
|
|
922
974
|
input.verbose === true,
|
|
923
|
-
compactAsyncRunStatus(
|
|
975
|
+
compactAsyncRunStatus(details),
|
|
924
976
|
),
|
|
925
977
|
},
|
|
926
978
|
],
|
|
927
|
-
details
|
|
979
|
+
details,
|
|
928
980
|
};
|
|
929
981
|
},
|
|
930
982
|
};
|
|
@@ -1031,7 +1083,11 @@ export function createInspectToolDefinition<TContext = unknown>(
|
|
|
1031
1083
|
},
|
|
1032
1084
|
{ root: deps.packagedRecipeRoot ?? Paths.getPackagedRecipeRoot() },
|
|
1033
1085
|
]);
|
|
1034
|
-
const
|
|
1086
|
+
const recipeRoot = deps.recipeRoot ?? Paths.getRecipeRoot();
|
|
1087
|
+
const summary = {
|
|
1088
|
+
...RecipeDiscovery.summarizeDiscovery(discovered),
|
|
1089
|
+
candidates: RecipeDiscovery.listCandidateRecipes(join(recipeRoot, "candidates")),
|
|
1090
|
+
};
|
|
1035
1091
|
return {
|
|
1036
1092
|
content: [
|
|
1037
1093
|
{
|
package/package.json
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"delay:int",
|
|
12
12
|
"locker:bool",
|
|
13
13
|
"locker_lease_ms:int",
|
|
14
|
+
"subagent_ttl_ms:int",
|
|
14
15
|
"artifact_path:path",
|
|
15
16
|
"repo:path"
|
|
16
17
|
],
|
|
@@ -23,6 +24,7 @@
|
|
|
23
24
|
"delay": "10",
|
|
24
25
|
"locker": "false",
|
|
25
26
|
"locker_lease_ms": "600000",
|
|
27
|
+
"subagent_ttl_ms": "0",
|
|
26
28
|
"artifact_path": "{state_dir}/room-swarm-artifact.md",
|
|
27
29
|
"repo": "~/.pi/agent/extensions/pi-actors"
|
|
28
30
|
},
|
|
@@ -44,5 +46,5 @@
|
|
|
44
46
|
"run.failed"
|
|
45
47
|
]
|
|
46
48
|
},
|
|
47
|
-
"template": "{repo}/scripts/coordinator.mjs --run-id={run_id} --mode={mode} --mission={mission} --model={model} --thinking={thinking} --roles={roles} --roles-path={roles_path} --rounds={rounds} --delay={delay} --locker={locker} --locker-lease-ms={locker_lease_ms} --artifact-path={artifact_path}"
|
|
49
|
+
"template": "{repo}/scripts/coordinator.mjs --run-id={run_id} --mode={mode} --mission={mission} --model={model} --thinking={thinking} --roles={roles} --roles-path={roles_path} --rounds={rounds} --delay={delay} --locker={locker} --locker-lease-ms={locker_lease_ms} --subagent-ttl-ms={subagent_ttl_ms} --artifact-path={artifact_path}"
|
|
48
50
|
}
|
package/skills/actors/SKILL.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: actors
|
|
3
3
|
description: Highest-density practical guide for pi-actors. Read this skill whenever prompt and tools are not enough for spawn, message, inspect, actor runs, tools, recipes, command templates, async lifecycle, mailboxes, artifacts, and local orchestration mechanics.
|
|
4
4
|
metadata:
|
|
5
|
-
version: 0.
|
|
5
|
+
version: 0.29.1
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Actors (pi-actors)
|
|
@@ -235,11 +235,16 @@ Priority for same-id recipes:
|
|
|
235
235
|
|
|
236
236
|
Only matching filename ids compete. Higher priority shadows lower priority; within one priority layer, same-id JSON shadows Markdown. An invalid or `disabled: true` higher-priority recipe blocks fallback so the agent does not silently run standard-library behavior when a user override is broken or intentionally disabled.
|
|
237
237
|
|
|
238
|
-
Muscle-memory lens:
|
|
238
|
+
Muscle-memory lens: pi-actors has two durable executable-memory layers.
|
|
239
|
+
|
|
240
|
+
1. `~/.pi/agent/recipes/*.json` and `*.md` are the agent's active capability memory. Every recipe in that directory becomes an easy-to-call tool automatically and survives into later sessions. Descriptions matter here because they become the tool's operator-facing title/context.
|
|
241
|
+
2. `~/.pi/agent/recipes/candidates/*.json` is candidate memory captured from successful inline `spawn template=...` runs. Candidates are not registered tools and do not enter the injected tool surface. They remain reusable by explicit path, e.g. `spawn file="~/.pi/agent/recipes/candidates/<name>.json"`, and can be promoted by moving or copying one level up into `~/.pi/agent/recipes`.
|
|
242
|
+
|
|
243
|
+
Agents grow active memory by calling `register_tool` or by deliberate recipe-file edits. They grow candidate memory by trying ad hoc actors successfully. Treat both as executable habits: candidates are the workbench/proving ground; root recipes are promoted muscle memory.
|
|
239
244
|
|
|
240
245
|
Usage lens: user recipes may carry extension-maintained launch metadata such as `usage.calls` and `usage.last_called`. The extension increments the counter when it starts that concrete recipe; agents should not hand-edit counters as part of normal recipe maintenance. Treat usage as evidence for usefulness analysis: heavily used recipes are good candidates for promotion, documentation, or stronger tests; unused recipes are cleanup candidates. Do not use failure counts as a primary usefulness signal because failures may reflect bad caller judgment rather than bad recipes. Do not delete or demote solely from counters without operator approval.
|
|
241
246
|
|
|
242
|
-
Promotion lens: successful transient/ad hoc actor runs are evidence, not commands. If
|
|
247
|
+
Promotion lens: successful transient/ad hoc actor runs are evidence, not commands. Inline spawns leave candidate recipes as replayable evidence, not active tools. If a candidate is repeatable, parameterized, safe enough, and likely useful later, the agent may promote it by moving/copying it into `~/.pi/agent/recipes` or by calling `register_tool` with a concise name, typed args/defaults, and a reviewed template or recipe path. Do not auto-register every success; do not promote temp paths, secrets, one-off prompts, or project-private assumptions without normalization and approval.
|
|
243
248
|
|
|
244
249
|
Cleanup rule: periodically inspect `~/.pi/agent/recipes` as the live muscle-memory set. For each stale, duplicate, too-specific, or low-value recipe, choose one explicit action: keep as a tool, move it out of the agent recipe root to retain recipe-only memory, merge into a better recipe, or delete/archive the file. Prefer moving over deletion when the recipe may still be useful as a component. Never silently remove tools during unrelated work.
|
|
245
250
|
|
|
@@ -297,7 +302,7 @@ Use packaged recipes by name with `spawn file=<name>` for async actors, or regis
|
|
|
297
302
|
- [`pipeline-docs-maintenance`](../../recipes/pipeline-docs-maintenance.json): docs index/review/planning → maintenance artifact.
|
|
298
303
|
- Artifacts: [`pipeline-artifact-report`](../../recipes/pipeline-artifact-report.json), [`pipeline-artifact-write`](../../recipes/pipeline-artifact-write.json), [`pipeline-artifact-bundle`](../../recipes/pipeline-artifact-bundle.json).
|
|
299
304
|
- Review gates: [`pipeline-quorum-review`](../../recipes/pipeline-quorum-review.json), [`pipeline-review-readiness`](../../recipes/pipeline-review-readiness.json).
|
|
300
|
-
- Task-first workflows: [`pipeline-architect-coordinator`](../../recipes/pipeline-architect-coordinator.json), [`pipeline-research-synthesis`](../../recipes/pipeline-research-synthesis.json), [`pipeline-development-tasking`](../../recipes/pipeline-development-tasking.json), [`pipeline-checkpoint-continuation`](../../recipes/pipeline-checkpoint-continuation.json), [`pipeline-media-library`](../../recipes/pipeline-media-library.json), [`pipeline-room-swarm`](../../recipes/pipeline-room-swarm.json). For room swarms, choose `mode` from `consensus`, `pipeline`, `fanout`, or `pool`; prefer `roles_path` for custom role JSON and keep role `name` ASCII-safe for branch addresses. Use `locker=true` when the swarm needs a coordinator-locker-backed artifact lock and journal.
|
|
305
|
+
- Task-first workflows: [`pipeline-architect-coordinator`](../../recipes/pipeline-architect-coordinator.json), [`pipeline-research-synthesis`](../../recipes/pipeline-research-synthesis.json), [`pipeline-development-tasking`](../../recipes/pipeline-development-tasking.json), [`pipeline-checkpoint-continuation`](../../recipes/pipeline-checkpoint-continuation.json), [`pipeline-media-library`](../../recipes/pipeline-media-library.json), [`pipeline-room-swarm`](../../recipes/pipeline-room-swarm.json). For room swarms, choose `mode` from `consensus`, `pipeline`, `fanout`, or `pool`; prefer `roles_path` for custom role JSON and keep role `name` ASCII-safe for branch addresses. Set `subagent_ttl_ms` when participant processes need a hard kill budget. Use `locker=true` when the swarm needs a coordinator-locker-backed artifact lock and journal.
|
|
301
306
|
|
|
302
307
|
### Utilities
|
|
303
308
|
|
package/skills/swarm/SKILL.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: swarm
|
|
3
3
|
description: Subagent orchestration with scoped locks and quorum consensus. Use for multi-model review, parallel scoped work, delegated audit, and coordinated subagent execution.
|
|
4
4
|
metadata:
|
|
5
|
-
version: 0.
|
|
5
|
+
version: 0.29.1
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Swarm
|
|
@@ -30,7 +30,8 @@ Maintain this skill as a living orchestration standard. When real swarm work exp
|
|
|
30
30
|
- `Async Run`: A local lifecycle envelope around a command-template swarm composer or utility. It owns state, logs, status, cancellation, and observability, not swarm semantics.
|
|
31
31
|
- `Lens`: A deliberately narrow cognitive role assigned to one subagent, such as security, tests, architecture, economics, or operator UX.
|
|
32
32
|
- `Task Card`: A bounded implementation assignment with goal, allowed files, avoided files, expected output, and validation gates.
|
|
33
|
-
- `Component Capability`: An abstract adapter operation such as launcher, reviewer, verifier, merger, quorum, checkpoint, follow-up, judge, or normalizer. Swarm may target these capabilities, but local adapters bind them to concrete tools, recipes, command templates, async runs, or services.
|
|
33
|
+
- `Component Capability`: An abstract adapter operation such as launcher, reviewer, verifier, merger, quorum, checkpoint, follow-up, judge, or normalizer. Swarm may target these capabilities, but local adapters bind them to concrete tools, recipes, candidate recipes, command templates, async runs, or services.
|
|
34
|
+
- `Candidate Recipe`: A reusable but non-registered recipe captured from a successful inline actor spawn. It can be replayed by explicit file path and later promoted into the active tool recipe root after enough dogfood.
|
|
34
35
|
- `Coordinator Checkpoint`: A deliberate subagent pause where the subagent preserves its working context, sends a bounded question or status to the orchestrator, receives a coordinator reply, and continues in the same subagent context.
|
|
35
36
|
- `Evidence Checkpoint`: A deliberate stop where a subagent records sources, assumptions, confidence, contradictions, or blocking evidence gaps before synthesis.
|
|
36
37
|
- `Integrator`: The human or agent that merges isolated branches/worktrees into the shared target and owns conflict resolution.
|