@narumitw/pi-subagents 1.0.1 → 2.0.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 +198 -188
- package/package.json +8 -8
- package/src/agents/built-ins.ts +13 -66
- package/src/agents/catalog.ts +19 -2
- package/src/agents/discovery.ts +31 -15
- package/src/auto-transport.ts +7 -1
- package/src/child-peer-bridge.ts +124 -0
- package/src/child-peer-tools.ts +132 -0
- package/src/completion-delivery.ts +19 -5
- package/src/completion-render.ts +189 -0
- package/src/completion-routing.ts +24 -0
- package/src/config-registration.ts +29 -4
- package/src/config-ui.ts +11 -17
- package/src/consult-registration.ts +3 -2
- package/src/consult-render.ts +1 -1
- package/src/create-stateful-transport.ts +15 -2
- package/src/execution-ui.ts +0 -72
- package/src/in-process-transport.ts +39 -7
- package/src/inspect-tool.ts +3 -1
- package/src/panel-planning.ts +2 -2
- package/src/panel-presets.ts +3 -0
- package/src/params.ts +1 -1
- package/src/peer-communication.ts +352 -0
- package/src/peer-transport.ts +49 -0
- package/src/persistence.ts +26 -1
- package/src/pi-args.ts +2 -0
- package/src/registry-types.ts +7 -0
- package/src/registry.ts +240 -41
- package/src/render.ts +2 -41
- package/src/result-contract.ts +20 -5
- package/src/rpc-transport.ts +56 -26
- package/src/runner.ts +13 -1
- package/src/settings.ts +4 -1
- package/src/spawn-idempotency.ts +2 -0
- package/src/stateful-agent-view.ts +3 -1
- package/src/stateful-guidance.ts +11 -11
- package/src/stateful-safety.ts +0 -45
- package/src/stateful-tool-params.ts +11 -3
- package/src/stateful.ts +359 -136
- package/src/subagents.ts +7 -9
- package/src/subprocess-transport.ts +49 -28
- package/src/task-path.ts +65 -0
- package/src/transport-ui.ts +0 -6
- package/src/transport.ts +2 -1
- package/src/usage-format.ts +42 -0
- package/src/workflow-ui.ts +4 -4
- package/src/automation-contract.ts +0 -709
- package/src/automation-planner.ts +0 -65
- package/src/automation-registration.ts +0 -137
- package/src/automation-tool.ts +0 -40
- package/src/automation.ts +0 -435
- package/src/execution-profiles.ts +0 -95
- package/src/workflow-plan-compiler.ts +0 -618
- package/src/workflow-plan-patch.ts +0 -636
- package/src/workflow-planning-benchmark.ts +0 -95
|
@@ -9,12 +9,18 @@ import {
|
|
|
9
9
|
} from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { discoverAgents } from "./agents/discovery.js";
|
|
11
11
|
import type { AgentConfig, SubagentThinkingLevel } from "./agents/types.js";
|
|
12
|
+
import { formatPeerMessage } from "./child-peer-tools.js";
|
|
12
13
|
import { redactPrivateText } from "./context.js";
|
|
13
14
|
import { appendDelegationContract } from "./delegation-contract.js";
|
|
14
15
|
import { resolveDefaultSubagentTimeoutMs } from "./execution/runtime-policy.js";
|
|
15
16
|
import { DEFAULT_MAX_CONTEXT_BYTES, DEFAULT_MAX_OUTPUT_BYTES, truncateUtf8 } from "./limits.js";
|
|
17
|
+
import {
|
|
18
|
+
CHILD_PEER_TOOL_NAMES,
|
|
19
|
+
createInProcessPeerExtension,
|
|
20
|
+
type PeerTransportRuntime,
|
|
21
|
+
} from "./peer-transport.js";
|
|
16
22
|
import { resolvePiPromptResources } from "./prompt-resources.js";
|
|
17
|
-
import type { AgentTurn, ManagedAgent, TurnOutcome } from "./registry.js";
|
|
23
|
+
import type { AgentMailboxMessage, AgentTurn, ManagedAgent, TurnOutcome } from "./registry.js";
|
|
18
24
|
import { appendResultInstruction } from "./result-contract.js";
|
|
19
25
|
import { safeTerminalLine } from "./safe-text.js";
|
|
20
26
|
import { readSubagentSettings } from "./settings.js";
|
|
@@ -79,6 +85,7 @@ export interface ChildSession {
|
|
|
79
85
|
readonly model?: string;
|
|
80
86
|
readonly thinkingLevel?: SubagentThinkingLevel;
|
|
81
87
|
prompt(text: string): Promise<void>;
|
|
88
|
+
steer?(text: string): Promise<void>;
|
|
82
89
|
subscribe(listener: (event: unknown) => void): () => void;
|
|
83
90
|
abort(): Promise<void>;
|
|
84
91
|
dispose(): void;
|
|
@@ -93,6 +100,7 @@ export interface ChildSessionCreateOptions {
|
|
|
93
100
|
modelRegistry: ModelRegistry;
|
|
94
101
|
parentRuntime: ParentRuntimeSnapshot;
|
|
95
102
|
tools?: string[];
|
|
103
|
+
peerRuntime?: PeerTransportRuntime;
|
|
96
104
|
}
|
|
97
105
|
|
|
98
106
|
export type ChildSessionFactory = (options: ChildSessionCreateOptions) => Promise<ChildSession>;
|
|
@@ -105,6 +113,7 @@ export interface InProcessTransportOptions {
|
|
|
105
113
|
defaultTimeoutMs?: number;
|
|
106
114
|
abortGraceMs?: number;
|
|
107
115
|
timeoutFinalizationMs?: number;
|
|
116
|
+
peerRuntime?: PeerTransportRuntime;
|
|
108
117
|
}
|
|
109
118
|
|
|
110
119
|
interface ChildSessionRecord {
|
|
@@ -362,6 +371,13 @@ export class InProcessTransport implements SubagentTransport {
|
|
|
362
371
|
}
|
|
363
372
|
}
|
|
364
373
|
|
|
374
|
+
async deliverMessage(agent: ManagedAgent, message: AgentMailboxMessage): Promise<boolean> {
|
|
375
|
+
const record = this.sessions.get(agent.id);
|
|
376
|
+
if (!record || record.disposed || !record.session.steer) return false;
|
|
377
|
+
await record.session.steer(formatPeerMessage(message));
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
|
|
365
381
|
async release(agent: ManagedAgent): Promise<void> {
|
|
366
382
|
await this.releaseById(agent.id);
|
|
367
383
|
}
|
|
@@ -384,6 +400,7 @@ export class InProcessTransport implements SubagentTransport {
|
|
|
384
400
|
const record = this.sessions.get(agentId);
|
|
385
401
|
if (!record) return;
|
|
386
402
|
this.sessions.delete(agentId);
|
|
403
|
+
this.options.peerRuntime?.revoke(agentId);
|
|
387
404
|
if (record.disposed) return;
|
|
388
405
|
record.disposed = true;
|
|
389
406
|
const failures: unknown[] = [];
|
|
@@ -423,6 +440,7 @@ export class InProcessTransport implements SubagentTransport {
|
|
|
423
440
|
modelRegistry: this.options.modelRegistry,
|
|
424
441
|
parentRuntime: this.options.getParentRuntime(),
|
|
425
442
|
tools,
|
|
443
|
+
peerRuntime: this.options.peerRuntime,
|
|
426
444
|
});
|
|
427
445
|
const record: ChildSessionRecord = {
|
|
428
446
|
session,
|
|
@@ -563,6 +581,9 @@ export async function createSdkChildSession(
|
|
|
563
581
|
agentDir,
|
|
564
582
|
options.agentConfig.systemPrompt,
|
|
565
583
|
projectTrusted,
|
|
584
|
+
options.peerRuntime
|
|
585
|
+
? createInProcessPeerExtension(options.peerRuntime, options.agent.id)
|
|
586
|
+
: undefined,
|
|
566
587
|
);
|
|
567
588
|
copyRegisteredProviders(
|
|
568
589
|
options.modelRegistry as unknown as RegisteredProviderRegistry,
|
|
@@ -577,18 +598,24 @@ export async function createSdkChildSession(
|
|
|
577
598
|
const model = resolved.model;
|
|
578
599
|
const sessionManager = SessionManager.inMemory(options.agent.cwd);
|
|
579
600
|
seedChildSessionManager(sessionManager, options, model);
|
|
601
|
+
const selectedTools =
|
|
602
|
+
options.tools === undefined
|
|
603
|
+
? undefined
|
|
604
|
+
: options.peerRuntime
|
|
605
|
+
? [...options.tools, ...CHILD_PEER_TOOL_NAMES]
|
|
606
|
+
: options.tools;
|
|
580
607
|
const created = await coreSupport.createAgentSessionFromServices({
|
|
581
608
|
services,
|
|
582
609
|
sessionManager,
|
|
583
610
|
model,
|
|
584
611
|
thinkingLevel: resolved.thinkingLevel,
|
|
585
|
-
tools:
|
|
586
|
-
noTools:
|
|
612
|
+
tools: selectedTools,
|
|
613
|
+
noTools: selectedTools?.length === 0 ? "all" : undefined,
|
|
587
614
|
});
|
|
588
615
|
const session = created.session;
|
|
589
|
-
if (
|
|
616
|
+
if (selectedTools !== undefined) {
|
|
590
617
|
const active = session.getActiveToolNames();
|
|
591
|
-
const expected = [...
|
|
618
|
+
const expected = [...selectedTools].sort();
|
|
592
619
|
if (
|
|
593
620
|
active.length !== expected.length ||
|
|
594
621
|
[...active].sort().some((name, index) => name !== expected[index])
|
|
@@ -616,6 +643,7 @@ export async function createSdkChildSession(
|
|
|
616
643
|
return session.thinkingLevel;
|
|
617
644
|
},
|
|
618
645
|
prompt: (text) => session.prompt(text),
|
|
646
|
+
steer: (text) => session.steer(text),
|
|
619
647
|
subscribe: (listener) => session.subscribe((event) => listener(event)),
|
|
620
648
|
abort: () => session.abort(),
|
|
621
649
|
dispose: () => session.dispose(),
|
|
@@ -713,6 +741,7 @@ async function prepareInProcessServices(
|
|
|
713
741
|
agentDir: string,
|
|
714
742
|
agentSystemPrompt: string,
|
|
715
743
|
projectTrusted: boolean,
|
|
744
|
+
peerExtension?: import("@earendil-works/pi-coding-agent").ExtensionFactory,
|
|
716
745
|
): Promise<{ services: AgentSessionServices; support: CoreSessionSupport }> {
|
|
717
746
|
const promptResources = await resolvePiPromptResources(cwd, projectTrusted, agentDir);
|
|
718
747
|
const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
|
|
@@ -724,6 +753,7 @@ async function prepareInProcessServices(
|
|
|
724
753
|
settingsManager,
|
|
725
754
|
resourceLoaderOptions: {
|
|
726
755
|
noExtensions: true,
|
|
756
|
+
...(peerExtension ? { extensionFactories: [peerExtension] } : {}),
|
|
727
757
|
appendSystemPrompt: [
|
|
728
758
|
...promptResources.appendSystemPromptPaths,
|
|
729
759
|
...(agentSystemPrompt.trim() ? [agentSystemPrompt] : []),
|
|
@@ -787,8 +817,10 @@ export function buildCurrentTurnPrompt(agent: ManagedAgent, task: string): strin
|
|
|
787
817
|
const messages = agent.mailbox
|
|
788
818
|
.filter((message) => ids.has(message.id))
|
|
789
819
|
.slice(-20)
|
|
790
|
-
.map((message) =>
|
|
791
|
-
|
|
820
|
+
.map((message) =>
|
|
821
|
+
formatPeerMessage({ ...message, content: redactPrivateText(message.content) }),
|
|
822
|
+
)
|
|
823
|
+
.join("\n\n");
|
|
792
824
|
const base = messages
|
|
793
825
|
? `${redactPrivateText(task)}\n\nMailbox messages:\n${messages}`
|
|
794
826
|
: redactPrivateText(task);
|
package/src/inspect-tool.ts
CHANGED
|
@@ -28,7 +28,9 @@ export const SubagentInspectParams = Type.Object(
|
|
|
28
28
|
{
|
|
29
29
|
action: StringEnum(INSPECT_ACTIONS),
|
|
30
30
|
agent: Type.Optional(Type.String({ minLength: 1 })),
|
|
31
|
-
agentId: Type.Optional(
|
|
31
|
+
agentId: Type.Optional(
|
|
32
|
+
Type.String({ minLength: 1, description: "Retained agent ID or canonical task path." }),
|
|
33
|
+
),
|
|
32
34
|
workflowId: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
|
|
33
35
|
agentScope: Type.Optional(AgentScopeSchema),
|
|
34
36
|
limit: Type.Optional(LimitSchema),
|
package/src/panel-planning.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { AgentConfig } from "./agents/types.js";
|
|
2
2
|
import { DEFAULT_MAX_CONTEXT_BYTES, truncateUtf8 } from "./limits.js";
|
|
3
|
+
import type { PanelPreset } from "./panel-presets.js";
|
|
3
4
|
import { type WorkItemDefinition, WorkItemLedger } from "./work-item-ledger.js";
|
|
4
5
|
|
|
5
|
-
export
|
|
6
|
-
export type PanelPreset = (typeof PANEL_PRESETS)[number];
|
|
6
|
+
export type { PanelPreset } from "./panel-presets.js";
|
|
7
7
|
|
|
8
8
|
export interface PanelReviewerRequest {
|
|
9
9
|
id: string;
|
package/src/params.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { type Static, Type } from "typebox";
|
|
|
3
3
|
import { THINKING_LEVELS } from "./agents/types.js";
|
|
4
4
|
import { DelegationContractSchema } from "./delegation-contract.js";
|
|
5
5
|
import { MAX_CONFIGURABLE_PARALLEL_TASKS, MAX_SUBAGENT_TIMEOUT_MS } from "./limits.js";
|
|
6
|
-
import { PANEL_PRESETS } from "./panel-
|
|
6
|
+
import { PANEL_PRESETS } from "./panel-presets.js";
|
|
7
7
|
import { SUBAGENT_RESULT_FORMATS } from "./result-contract.js";
|
|
8
8
|
import { MAX_SUBAGENT_TOOL_CALLS, MAX_SUBAGENT_TURNS } from "./turn-budget.js";
|
|
9
9
|
import { VerifiedExecutionContractSchema } from "./verified-execution-schema.js";
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import net, { type AddressInfo, type Server, type Socket } from "node:net";
|
|
3
|
+
import { redactPrivateText } from "./context.js";
|
|
4
|
+
import { truncateUtf8 } from "./limits.js";
|
|
5
|
+
import type { AgentMailboxMessage, AgentRegistry, ManagedAgent } from "./registry.js";
|
|
6
|
+
import { ROOT_TASK_PATH } from "./task-path.js";
|
|
7
|
+
|
|
8
|
+
const MAX_BRIDGE_FRAME_BYTES = 64 * 1024;
|
|
9
|
+
const MAX_BRIDGE_CONNECTIONS = 32;
|
|
10
|
+
const BRIDGE_HANDSHAKE_TIMEOUT_MS = 2_000;
|
|
11
|
+
const MAX_ROOT_MESSAGE_BYTES = 16 * 1024;
|
|
12
|
+
const MAX_LISTED_PEERS = 20;
|
|
13
|
+
|
|
14
|
+
export interface PeerDescriptor {
|
|
15
|
+
id: string;
|
|
16
|
+
taskName: string;
|
|
17
|
+
taskPath: string;
|
|
18
|
+
agent: string;
|
|
19
|
+
state: string;
|
|
20
|
+
self: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PeerRootMessage {
|
|
24
|
+
message: AgentMailboxMessage;
|
|
25
|
+
senderPath: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PeerBridgeCredentials {
|
|
29
|
+
host: string;
|
|
30
|
+
port: number;
|
|
31
|
+
token: string;
|
|
32
|
+
generation: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PeerCommunicationBrokerOptions {
|
|
36
|
+
getRegistry(): AgentRegistry;
|
|
37
|
+
sendRoot(message: PeerRootMessage): void | Promise<void>;
|
|
38
|
+
dispatch?(recipient: ManagedAgent, message: AgentMailboxMessage): boolean | Promise<boolean>;
|
|
39
|
+
now?: () => number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface CredentialRecord extends PeerBridgeCredentials {
|
|
43
|
+
agentId: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Session-owned authenticated routing and process-child JSONL bridge. */
|
|
47
|
+
export class PeerCommunicationBroker {
|
|
48
|
+
private server?: Server;
|
|
49
|
+
private starting?: Promise<void>;
|
|
50
|
+
private closed = false;
|
|
51
|
+
private readonly sockets = new Set<Socket>();
|
|
52
|
+
private readonly credentialsByToken = new Map<string, CredentialRecord>();
|
|
53
|
+
private readonly tokenByAgent = new Map<string, string>();
|
|
54
|
+
private readonly dispatchedIds = new Set<string>();
|
|
55
|
+
private readonly rootDeduplication = new Map<string, AgentMailboxMessage>();
|
|
56
|
+
|
|
57
|
+
constructor(private readonly options: PeerCommunicationBrokerOptions) {}
|
|
58
|
+
|
|
59
|
+
async send(
|
|
60
|
+
senderId: string,
|
|
61
|
+
target: string,
|
|
62
|
+
content: string,
|
|
63
|
+
deduplicationKey?: string,
|
|
64
|
+
): Promise<AgentMailboxMessage> {
|
|
65
|
+
if (this.closed) throw new Error("Subagent peer broker is closed");
|
|
66
|
+
const registry = this.options.getRegistry();
|
|
67
|
+
const sender = registry.resolveAgent(senderId);
|
|
68
|
+
if (!sender) throw new Error(`Unknown subagent: ${senderId}`);
|
|
69
|
+
if (sender.state === "closed")
|
|
70
|
+
throw new Error(`Closed agent ${sender.id} cannot send messages`);
|
|
71
|
+
if (target === ROOT_TASK_PATH) {
|
|
72
|
+
return this.sendToRoot(sender, content, deduplicationKey);
|
|
73
|
+
}
|
|
74
|
+
const message = await registry.sendMessage(target, content, sender.id, deduplicationKey);
|
|
75
|
+
if (this.closed) return message;
|
|
76
|
+
const recipient = registry.get(message.recipientId);
|
|
77
|
+
if (recipient?.state === "running" && !this.dispatchedIds.has(message.id)) {
|
|
78
|
+
this.dispatchedIds.add(message.id);
|
|
79
|
+
try {
|
|
80
|
+
await this.options.dispatch?.(recipient, redactedDeliveryCopy(message));
|
|
81
|
+
} catch {
|
|
82
|
+
// Durable mailbox delivery remains available for the recipient's next turn.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return message;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
list(senderId: string): PeerDescriptor[] {
|
|
89
|
+
const registry = this.options.getRegistry();
|
|
90
|
+
const sender = registry.resolveAgent(senderId);
|
|
91
|
+
if (!sender) throw new Error(`Unknown subagent: ${senderId}`);
|
|
92
|
+
const root: PeerDescriptor = {
|
|
93
|
+
id: "root",
|
|
94
|
+
taskName: "root",
|
|
95
|
+
taskPath: ROOT_TASK_PATH,
|
|
96
|
+
agent: "root",
|
|
97
|
+
state: "active",
|
|
98
|
+
self: false,
|
|
99
|
+
};
|
|
100
|
+
const retained = registry
|
|
101
|
+
.list()
|
|
102
|
+
.map((agent) => ({
|
|
103
|
+
id: truncateUtf8(agent.id, 256).text,
|
|
104
|
+
taskName: truncateUtf8(agent.taskName ?? "unknown", 128).text,
|
|
105
|
+
taskPath: truncateUtf8(agent.taskPath ?? agent.id, 2_048).text,
|
|
106
|
+
agent: truncateUtf8(agent.agent, 128).text,
|
|
107
|
+
state: agent.state,
|
|
108
|
+
self: agent.id === sender.id,
|
|
109
|
+
}))
|
|
110
|
+
.sort((left, right) => left.taskPath.localeCompare(right.taskPath));
|
|
111
|
+
const selected = retained.slice(0, MAX_LISTED_PEERS - 1);
|
|
112
|
+
if (!selected.some((peer) => peer.id === sender.id)) {
|
|
113
|
+
const senderPeer = retained.find((peer) => peer.id === sender.id);
|
|
114
|
+
if (senderPeer) selected.splice(Math.max(0, selected.length - 1), 1, senderPeer);
|
|
115
|
+
}
|
|
116
|
+
return [root, ...selected].sort((left, right) => left.taskPath.localeCompare(right.taskPath));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async acknowledge(
|
|
120
|
+
agentId: string,
|
|
121
|
+
messageIds: readonly string[],
|
|
122
|
+
completionIds: readonly string[] = [],
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
if (this.closed) return;
|
|
125
|
+
await this.options
|
|
126
|
+
.getRegistry()
|
|
127
|
+
.acknowledgeVisibleMessages(
|
|
128
|
+
agentId,
|
|
129
|
+
messageIds,
|
|
130
|
+
completionIds,
|
|
131
|
+
(this.options.now ?? Date.now)(),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async issueCredentials(agentId: string, generation: number): Promise<PeerBridgeCredentials> {
|
|
136
|
+
if (this.closed) throw new Error("Subagent peer broker is closed");
|
|
137
|
+
if (!Number.isSafeInteger(generation) || generation < 1) {
|
|
138
|
+
throw new Error("Subagent peer bridge generation must be a positive safe integer");
|
|
139
|
+
}
|
|
140
|
+
const agent = this.options.getRegistry().resolveAgent(agentId);
|
|
141
|
+
if (!agent || agent.state === "closed") throw new Error(`Unknown subagent: ${agentId}`);
|
|
142
|
+
await this.ensureServer();
|
|
143
|
+
this.revoke(agent.id);
|
|
144
|
+
const address = this.server?.address();
|
|
145
|
+
if (!address || typeof address === "string") {
|
|
146
|
+
throw new Error("Subagent peer bridge did not expose a loopback address");
|
|
147
|
+
}
|
|
148
|
+
const token = randomBytes(32).toString("hex");
|
|
149
|
+
const credentials: CredentialRecord = {
|
|
150
|
+
agentId: agent.id,
|
|
151
|
+
host: "127.0.0.1",
|
|
152
|
+
port: address.port,
|
|
153
|
+
token,
|
|
154
|
+
generation,
|
|
155
|
+
};
|
|
156
|
+
this.credentialsByToken.set(token, credentials);
|
|
157
|
+
this.tokenByAgent.set(agent.id, token);
|
|
158
|
+
return { host: credentials.host, port: credentials.port, token, generation };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
revoke(agentId: string): void {
|
|
162
|
+
const token = this.tokenByAgent.get(agentId);
|
|
163
|
+
if (!token) return;
|
|
164
|
+
this.tokenByAgent.delete(agentId);
|
|
165
|
+
this.credentialsByToken.delete(token);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async close(): Promise<void> {
|
|
169
|
+
if (this.closed) return;
|
|
170
|
+
this.closed = true;
|
|
171
|
+
this.credentialsByToken.clear();
|
|
172
|
+
this.tokenByAgent.clear();
|
|
173
|
+
this.rootDeduplication.clear();
|
|
174
|
+
this.dispatchedIds.clear();
|
|
175
|
+
for (const socket of this.sockets) socket.destroy();
|
|
176
|
+
this.sockets.clear();
|
|
177
|
+
const server = this.server;
|
|
178
|
+
this.server = undefined;
|
|
179
|
+
if (!server) return;
|
|
180
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private async sendToRoot(
|
|
184
|
+
sender: ManagedAgent,
|
|
185
|
+
content: string,
|
|
186
|
+
deduplicationKey?: string,
|
|
187
|
+
): Promise<AgentMailboxMessage> {
|
|
188
|
+
if (!content.trim()) throw new Error("Subagent peer messages cannot be empty");
|
|
189
|
+
if (deduplicationKey && deduplicationKey.length > 256) {
|
|
190
|
+
throw new Error("Subagent peer deduplication keys cannot exceed 256 characters");
|
|
191
|
+
}
|
|
192
|
+
const deduplicationId = deduplicationKey ? `${sender.id}\0${deduplicationKey}` : undefined;
|
|
193
|
+
const duplicate = deduplicationId ? this.rootDeduplication.get(deduplicationId) : undefined;
|
|
194
|
+
if (duplicate) return { ...duplicate };
|
|
195
|
+
const message: AgentMailboxMessage = {
|
|
196
|
+
id: `msg_${randomUUID()}`,
|
|
197
|
+
senderId: sender.id,
|
|
198
|
+
recipientId: "root",
|
|
199
|
+
content: truncateUtf8(content, MAX_ROOT_MESSAGE_BYTES).text,
|
|
200
|
+
createdAt: (this.options.now ?? Date.now)(),
|
|
201
|
+
deduplicationKey,
|
|
202
|
+
};
|
|
203
|
+
await this.options.sendRoot({
|
|
204
|
+
message: redactedDeliveryCopy(message),
|
|
205
|
+
senderPath: sender.taskPath ?? sender.id,
|
|
206
|
+
});
|
|
207
|
+
if (deduplicationId) {
|
|
208
|
+
this.rootDeduplication.set(deduplicationId, message);
|
|
209
|
+
while (this.rootDeduplication.size > 100) {
|
|
210
|
+
const oldest = this.rootDeduplication.keys().next().value;
|
|
211
|
+
if (typeof oldest !== "string") break;
|
|
212
|
+
this.rootDeduplication.delete(oldest);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { ...message };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private async ensureServer(): Promise<void> {
|
|
219
|
+
if (this.server) return;
|
|
220
|
+
if (!this.starting) {
|
|
221
|
+
this.starting = new Promise<void>((resolve, reject) => {
|
|
222
|
+
const server = net.createServer((socket) => this.accept(socket));
|
|
223
|
+
server.maxConnections = MAX_BRIDGE_CONNECTIONS;
|
|
224
|
+
server.once("error", reject);
|
|
225
|
+
server.listen({ host: "127.0.0.1", port: 0 }, () => {
|
|
226
|
+
server.off("error", reject);
|
|
227
|
+
this.server = server;
|
|
228
|
+
resolve();
|
|
229
|
+
});
|
|
230
|
+
}).finally(() => {
|
|
231
|
+
this.starting = undefined;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
await this.starting;
|
|
235
|
+
if (this.closed) {
|
|
236
|
+
const server = this.server as Server | undefined;
|
|
237
|
+
this.server = undefined;
|
|
238
|
+
if (server) await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
239
|
+
throw new Error("Subagent peer broker closed while starting");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private accept(socket: Socket): void {
|
|
244
|
+
if (this.closed || this.sockets.size >= MAX_BRIDGE_CONNECTIONS) {
|
|
245
|
+
socket.destroy();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
this.sockets.add(socket);
|
|
249
|
+
let frame = Buffer.alloc(0);
|
|
250
|
+
let handled = false;
|
|
251
|
+
const timer = setTimeout(() => {
|
|
252
|
+
handled = true;
|
|
253
|
+
this.respond(socket, { ok: false, error: "bridge handshake timed out" });
|
|
254
|
+
}, BRIDGE_HANDSHAKE_TIMEOUT_MS);
|
|
255
|
+
timer.unref();
|
|
256
|
+
const cleanup = () => {
|
|
257
|
+
clearTimeout(timer);
|
|
258
|
+
this.sockets.delete(socket);
|
|
259
|
+
};
|
|
260
|
+
socket.on("data", (chunk: Buffer) => {
|
|
261
|
+
if (handled) return;
|
|
262
|
+
frame = Buffer.concat([frame, chunk]);
|
|
263
|
+
if (frame.byteLength > MAX_BRIDGE_FRAME_BYTES) {
|
|
264
|
+
handled = true;
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
this.respond(socket, { ok: false, error: "bridge frame exceeds size limit" });
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const newline = frame.indexOf(0x0a);
|
|
270
|
+
if (newline < 0) return;
|
|
271
|
+
handled = true;
|
|
272
|
+
clearTimeout(timer);
|
|
273
|
+
void this.handleFrame(frame.subarray(0, newline).toString("utf8")).then((response) =>
|
|
274
|
+
this.respond(socket, response),
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
socket.once("close", cleanup);
|
|
278
|
+
socket.once("error", cleanup);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private async handleFrame(frame: string): Promise<Record<string, unknown>> {
|
|
282
|
+
let request: Record<string, unknown>;
|
|
283
|
+
try {
|
|
284
|
+
const parsed = JSON.parse(frame) as unknown;
|
|
285
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
|
|
286
|
+
request = parsed as Record<string, unknown>;
|
|
287
|
+
} catch {
|
|
288
|
+
return { ok: false, error: "malformed bridge request" };
|
|
289
|
+
}
|
|
290
|
+
const credential =
|
|
291
|
+
typeof request.token === "string" ? this.credentialsByToken.get(request.token) : undefined;
|
|
292
|
+
if (!credential) return { ok: false, error: "unauthenticated bridge request" };
|
|
293
|
+
try {
|
|
294
|
+
switch (request.action) {
|
|
295
|
+
case "send": {
|
|
296
|
+
if (typeof request.target !== "string" || typeof request.message !== "string") {
|
|
297
|
+
throw new Error("send requires target and message strings");
|
|
298
|
+
}
|
|
299
|
+
const message = await this.send(
|
|
300
|
+
credential.agentId,
|
|
301
|
+
request.target,
|
|
302
|
+
request.message,
|
|
303
|
+
typeof request.deduplicationKey === "string" ? request.deduplicationKey : undefined,
|
|
304
|
+
);
|
|
305
|
+
return { ok: true, message };
|
|
306
|
+
}
|
|
307
|
+
case "list":
|
|
308
|
+
return { ok: true, peers: this.list(credential.agentId) };
|
|
309
|
+
case "acknowledge": {
|
|
310
|
+
const messageIds = stringArray(request.messageIds);
|
|
311
|
+
const completionIds = stringArray(request.completionIds);
|
|
312
|
+
await this.acknowledge(credential.agentId, messageIds, completionIds);
|
|
313
|
+
return { ok: true };
|
|
314
|
+
}
|
|
315
|
+
default:
|
|
316
|
+
return { ok: false, error: "unsupported bridge action" };
|
|
317
|
+
}
|
|
318
|
+
} catch (error) {
|
|
319
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private respond(socket: Socket, value: Record<string, unknown>): void {
|
|
324
|
+
if (socket.destroyed) return;
|
|
325
|
+
const content = `${JSON.stringify(value)}\n`;
|
|
326
|
+
socket.end(content);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function redactedDeliveryCopy(message: AgentMailboxMessage): AgentMailboxMessage {
|
|
331
|
+
return { ...message, content: redactPrivateText(message.content) };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function stringArray(value: unknown): string[] {
|
|
335
|
+
if (value === undefined) return [];
|
|
336
|
+
if (
|
|
337
|
+
!Array.isArray(value) ||
|
|
338
|
+
value.length > 100 ||
|
|
339
|
+
value.some((item) => typeof item !== "string")
|
|
340
|
+
) {
|
|
341
|
+
throw new Error("acknowledgement IDs must be a bounded string array");
|
|
342
|
+
}
|
|
343
|
+
return value;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function peerBridgeAddress(credentials: PeerBridgeCredentials): string {
|
|
347
|
+
return `${credentials.host}:${credentials.port}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function isLoopbackAddress(address: AddressInfo | string | null): address is AddressInfo {
|
|
351
|
+
return Boolean(address && typeof address !== "string" && address.address === "127.0.0.1");
|
|
352
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { createChildPeerExtension } from "./child-peer-tools.js";
|
|
4
|
+
import type { PeerBridgeCredentials, PeerDescriptor } from "./peer-communication.js";
|
|
5
|
+
import type { AgentMailboxMessage } from "./registry.js";
|
|
6
|
+
|
|
7
|
+
export const CHILD_PEER_TOOL_NAMES = ["subagent_peer_send", "subagent_peer_list"] as const;
|
|
8
|
+
|
|
9
|
+
export interface PeerTransportRuntime {
|
|
10
|
+
send(
|
|
11
|
+
senderId: string,
|
|
12
|
+
target: string,
|
|
13
|
+
message: string,
|
|
14
|
+
deduplicationKey?: string,
|
|
15
|
+
): Promise<AgentMailboxMessage>;
|
|
16
|
+
list(senderId: string): PeerDescriptor[];
|
|
17
|
+
acknowledge(
|
|
18
|
+
agentId: string,
|
|
19
|
+
messageIds: readonly string[],
|
|
20
|
+
completionIds?: readonly string[],
|
|
21
|
+
): Promise<void>;
|
|
22
|
+
issueCredentials(agentId: string, generation: number): Promise<PeerBridgeCredentials>;
|
|
23
|
+
revoke(agentId: string): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createInProcessPeerExtension(
|
|
27
|
+
runtime: PeerTransportRuntime,
|
|
28
|
+
agentId: string,
|
|
29
|
+
): ExtensionFactory {
|
|
30
|
+
return createChildPeerExtension({
|
|
31
|
+
send: (target, message, deduplicationKey) =>
|
|
32
|
+
runtime.send(agentId, target, message, deduplicationKey),
|
|
33
|
+
list: async () => runtime.list(agentId),
|
|
34
|
+
acknowledge: (messageIds, completionIds) =>
|
|
35
|
+
runtime.acknowledge(agentId, messageIds, completionIds),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function childPeerBridgePath(): string {
|
|
40
|
+
return fileURLToPath(new URL("./child-peer-bridge.ts", import.meta.url));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function peerBridgeEnvironment(credentials: PeerBridgeCredentials): NodeJS.ProcessEnv {
|
|
44
|
+
return {
|
|
45
|
+
PI_SUBAGENT_PEER_HOST: credentials.host,
|
|
46
|
+
PI_SUBAGENT_PEER_PORT: String(credentials.port),
|
|
47
|
+
PI_SUBAGENT_PEER_TOKEN: credentials.token,
|
|
48
|
+
};
|
|
49
|
+
}
|
package/src/persistence.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { ManagedAgent } from "./registry.js";
|
|
|
13
13
|
import { parseAnyStructuredSubagentResult, SUBAGENT_RESULT_FORMATS } from "./result-contract.js";
|
|
14
14
|
import { isSemanticSnapshot } from "./semantic-snapshot.js";
|
|
15
15
|
import { resolveStatefulLimits } from "./stateful-limits.js";
|
|
16
|
+
import { validateTaskName, validateTaskPath } from "./task-path.js";
|
|
16
17
|
import { copyTurnTerminationReport, type TurnTerminationReport } from "./timeout-checkpoint.js";
|
|
17
18
|
import { MAX_SUBAGENT_TOOL_CALLS, MAX_SUBAGENT_TURNS } from "./turn-budget.js";
|
|
18
19
|
|
|
@@ -239,6 +240,8 @@ function isStoredState(value: unknown): value is StoredState {
|
|
|
239
240
|
const record = agent as Partial<ManagedAgent>;
|
|
240
241
|
return (
|
|
241
242
|
typeof record.id === "string" &&
|
|
243
|
+
(record.taskName === undefined || isValidTaskName(record.taskName)) &&
|
|
244
|
+
(record.taskPath === undefined || isValidTaskPath(record.taskPath)) &&
|
|
242
245
|
typeof record.agent === "string" &&
|
|
243
246
|
typeof record.cwd === "string" &&
|
|
244
247
|
typeof record.createdAt === "number" &&
|
|
@@ -291,6 +294,24 @@ function isStoredState(value: unknown): value is StoredState {
|
|
|
291
294
|
});
|
|
292
295
|
}
|
|
293
296
|
|
|
297
|
+
function isValidTaskName(value: unknown): value is string {
|
|
298
|
+
if (typeof value !== "string") return false;
|
|
299
|
+
try {
|
|
300
|
+
return validateTaskName(value) === value;
|
|
301
|
+
} catch {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function isValidTaskPath(value: unknown): value is string {
|
|
307
|
+
if (typeof value !== "string") return false;
|
|
308
|
+
try {
|
|
309
|
+
return validateTaskPath(value) === value;
|
|
310
|
+
} catch {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
294
315
|
function isSemanticCompatibility(value: unknown): boolean {
|
|
295
316
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
296
317
|
const compatibility = value as Record<string, unknown>;
|
|
@@ -434,6 +455,9 @@ function isPersistedCompletion(
|
|
|
434
455
|
typeof completion.completionId === "string" &&
|
|
435
456
|
completion.completionId.length > 0 &&
|
|
436
457
|
completion.completionId.length <= 256 &&
|
|
458
|
+
(completion.recipientId === undefined ||
|
|
459
|
+
(typeof completion.recipientId === "string" && completion.recipientId.length > 0)) &&
|
|
460
|
+
(completion.recipientPath === undefined || isValidTaskPath(completion.recipientPath)) &&
|
|
437
461
|
typeof completion.runId === "string" &&
|
|
438
462
|
completion.runId.length > 0 &&
|
|
439
463
|
completion.runId.length <= 256 &&
|
|
@@ -483,6 +507,7 @@ function isMailboxMessage(value: unknown): boolean {
|
|
|
483
507
|
Number.isFinite(message.createdAt) &&
|
|
484
508
|
(message.readAt === undefined ||
|
|
485
509
|
(typeof message.readAt === "number" && Number.isFinite(message.readAt))) &&
|
|
486
|
-
(message.deduplicationKey === undefined || typeof message.deduplicationKey === "string")
|
|
510
|
+
(message.deduplicationKey === undefined || typeof message.deduplicationKey === "string") &&
|
|
511
|
+
(message.completionId === undefined || typeof message.completionId === "string")
|
|
487
512
|
);
|
|
488
513
|
}
|
package/src/pi-args.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface PiArgsOptions {
|
|
|
11
11
|
projectTrust?: boolean;
|
|
12
12
|
baseSystemPromptPath?: string;
|
|
13
13
|
appendSystemPromptPaths?: string[];
|
|
14
|
+
extensionPaths?: string[];
|
|
14
15
|
/** Existing single append prompt path retained for compatibility. */
|
|
15
16
|
systemPromptPath?: string;
|
|
16
17
|
task: string;
|
|
@@ -21,6 +22,7 @@ export function buildPiArgs(options: PiArgsOptions): string[] {
|
|
|
21
22
|
if (options.model) args.push("--model", options.model);
|
|
22
23
|
if (options.thinkingLevel) args.push("--thinking", options.thinkingLevel);
|
|
23
24
|
if (options.disableExtensions) args.push("--no-extensions");
|
|
25
|
+
for (const extensionPath of options.extensionPaths ?? []) args.push("-e", extensionPath);
|
|
24
26
|
if (options.disableSkills) args.push("--no-skills");
|
|
25
27
|
if (options.disablePromptTemplates) args.push("--no-prompt-templates");
|
|
26
28
|
if (options.disableContextFiles) args.push("--no-context-files");
|