Skip to content

Commit e2ea963

Browse files
authored
feat: SharedStore for parallel workflow agent communication (#47)
Add an in-memory SharedStore (store_put/store_get tools) injected into every agent so parallel agents in a run can share intermediate state, with per-agent delta journaling for correct resume replay. Deltas are keyed by runId:callIndex so a nested workflow() sharing the parent store can't collide on callIndex. System tools bypass the agentType allowlist. Co-authored-by gr3enarr0w.
1 parent c52ad65 commit e2ea963

5 files changed

Lines changed: 642 additions & 20 deletions

File tree

src/agent.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,13 @@ export interface AgentRunOptions<TSchemaDef extends TSchema | undefined = undefi
292292
* structured_output) before falling back to strict prose extraction. Default 2.
293293
*/
294294
maxSchemaRetries?: number;
295+
/**
296+
* Tools that are always injected AFTER the tool-policy filter (`toolNames` /
297+
* `disallowedToolNames`), so they are available even under a restrictive
298+
* allowlist. Used by the workflow runtime to inject shared-store tools into
299+
* every agent regardless of its agentType definition.
300+
*/
301+
systemTools?: ToolDefinition[];
295302
/**
296303
* Per-run model registry override. Takes precedence over the constructor's
297304
* `modelRegistry` (WorkflowAgentOptions.modelRegistry) for both model
@@ -379,6 +386,11 @@ export class WorkflowAgent {
379386
options.disallowedToolNames,
380387
);
381388

389+
// System tools bypass the allowlist/denylist filter (e.g. shared-store tools).
390+
if (options.systemTools?.length) {
391+
customTools.push(...options.systemTools);
392+
}
393+
382394
if (options.schema) {
383395
customTools.push(createStructuredOutputTool({ schema: options.schema, capture }) as unknown as ToolDefinition);
384396
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export {
6262
registerAllSavedWorkflows,
6363
registerSavedWorkflow,
6464
} from "./saved-commands.js";
65+
export { createSharedStoreTools, SharedStore } from "./shared-store.js";
6566
export type { StructuredOutputCapture, StructuredOutputToolOptions } from "./structured-output.js";
6667
export { createStructuredOutputTool } from "./structured-output.js";
6768
export { deliverText, installResultDelivery, installTaskPanel, type TaskPanelOptions } from "./task-panel.js";

src/shared-store.ts

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/**
2+
* In-memory key-value store scoped to a single workflow run.
3+
*
4+
* One `SharedStore` instance is created at run start and disposed when the run
5+
* ends. Two MCP-compatible tool definitions (`store_put` / `store_get`) are
6+
* injected into every agent's tool list so parallel agents can share
7+
* intermediate state without coordinating through the script itself.
8+
*
9+
* Journal integration: callers capture `store.commitDelta(deltaKey)` alongside
10+
* each agent result in the journal. On resume, `store.applyDelta(delta)` rebuilds
11+
* the store state additively in callSeq order, so parallel-agent writes are
12+
* replayed correctly without the last-complete-wins ordering bug that a
13+
* whole-Map restore() would cause.
14+
*
15+
* `deltaKey` must be unique across every run that shares this store instance,
16+
* not just within one run's callSeq. A nested `workflow()` call restarts its own
17+
* callSeq at 0 while inheriting the parent's store (so parent and nested-run
18+
* agents can share state), so a bare callIndex would collide between a parent
19+
* agent and a concurrently-running nested-run agent that both got index 0 —
20+
* whichever commits its delta last would clobber the other's entry in
21+
* `agentDeltas`. Callers compose `deltaKey` as `${runId}:${callIndex}`, and
22+
* since every run (including each nested run) gets its own distinct `runId`,
23+
* the composite key is unique across the whole store's lifetime.
24+
*/
25+
26+
import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
27+
import { Type } from "typebox";
28+
29+
export class SharedStore {
30+
private readonly map = new Map<string, unknown>();
31+
// Per-agent write deltas for delta-journaling; keyed by a run-unique
32+
// `${runId}:${callIndex}` string (see class doc) so nested workflow() runs
33+
// sharing this store can't collide on a bare callIndex.
34+
private readonly agentDeltas = new Map<string, Record<string, unknown>>();
35+
36+
/** Store a value under `key`. Overwrites any existing value. */
37+
put(key: string, value: unknown): void {
38+
this.map.set(key, value);
39+
}
40+
41+
/**
42+
* Store a value and record the write in the per-agent delta for `deltaKey`
43+
* (a run-unique `${runId}:${callIndex}` string — see class doc). Used by
44+
* per-agent tools created via `createAgentStoreTools` so that each agent's
45+
* writes can be journaled and replayed independently.
46+
*/
47+
trackPut(key: string, value: unknown, deltaKey: string): void {
48+
this.map.set(key, value);
49+
let delta = this.agentDeltas.get(deltaKey);
50+
if (!delta) {
51+
delta = {};
52+
this.agentDeltas.set(deltaKey, delta);
53+
}
54+
delta[key] = value;
55+
}
56+
57+
/** Retrieve the value for `key`, or `undefined` when absent. */
58+
get(key: string): unknown {
59+
return this.map.get(key);
60+
}
61+
62+
/** Whether `key` is present in the store. */
63+
has(key: string): boolean {
64+
return this.map.has(key);
65+
}
66+
67+
/** Return a deep-copied plain-object snapshot of all entries. */
68+
snapshot(): Record<string, unknown> {
69+
return structuredClone(Object.fromEntries(this.map));
70+
}
71+
72+
/**
73+
* Extract and clear the write delta accumulated for `deltaKey`.
74+
* Called after an agent completes to get the set of keys it wrote.
75+
*/
76+
commitDelta(deltaKey: string): Record<string, unknown> {
77+
const delta = this.agentDeltas.get(deltaKey) ?? {};
78+
this.agentDeltas.delete(deltaKey);
79+
return delta;
80+
}
81+
82+
/**
83+
* Apply a write delta additively — sets each key without clearing others.
84+
* Used during resume replay so parallel-agent deltas applied in callSeq
85+
* order accumulate correctly regardless of original completion order.
86+
*/
87+
applyDelta(delta: Record<string, unknown>): void {
88+
for (const [k, v] of Object.entries(delta)) {
89+
this.map.set(k, v);
90+
}
91+
}
92+
93+
/**
94+
* Replace all entries with a snapshot (for full resets).
95+
* Prefer `applyDelta` for resume replay — see journal integration above.
96+
*/
97+
restore(snap: Record<string, unknown>): void {
98+
this.map.clear();
99+
for (const [k, v] of Object.entries(snap)) {
100+
this.map.set(k, v);
101+
}
102+
}
103+
104+
/** Clear all entries (called when the run ends). */
105+
dispose(): void {
106+
this.map.clear();
107+
this.agentDeltas.clear();
108+
}
109+
}
110+
111+
/**
112+
* Create the `store_put` and `store_get` tool definitions bound to a specific
113+
* `SharedStore` instance. Inject the returned array into every agent in the run
114+
* via `systemTools` so all agents, including those with a restrictive
115+
* `tools` allowlist, can read and write shared state.
116+
*
117+
* For workflow-internal use where delta-journaling is needed, use
118+
* `createAgentStoreTools(store, deltaKey)` instead — it attributes each put to
119+
* the given agent (via a run-unique deltaKey) so the write can be replayed
120+
* correctly on resume.
121+
*/
122+
export function createSharedStoreTools(store: SharedStore): ToolDefinition[] {
123+
const storePut = defineTool({
124+
name: "store_put",
125+
label: "Store Put",
126+
description:
127+
"Write a value to the shared run store. Any other agent in this workflow run can read it with store_get. Overwrites any existing value for the key. Note: when two parallel agents write the same key, the last write wins — no merge is performed.",
128+
promptSnippet: "Write a value to the shared store",
129+
parameters: Type.Object({
130+
key: Type.String({ description: "The key to store the value under." }),
131+
value: Type.Any({ description: "The value to store (any JSON-serializable value)." }),
132+
}),
133+
async execute(_id: string, params: { key: string; value: unknown }) {
134+
store.put(params.key, params.value);
135+
return {
136+
content: [{ type: "text", text: `Stored value under key "${params.key}".` }],
137+
details: { key: params.key },
138+
};
139+
},
140+
}) as unknown as ToolDefinition;
141+
142+
const storeGet = defineTool({
143+
name: "store_get",
144+
label: "Store Get",
145+
description:
146+
"Read a value from the shared run store previously written by store_put. Returns the stored value, or null when the key does not exist.",
147+
promptSnippet: "Read a value from the shared store",
148+
parameters: Type.Object({
149+
key: Type.String({ description: "The key to read." }),
150+
}),
151+
async execute(_id: string, params: { key: string }) {
152+
const found = store.has(params.key);
153+
const value = store.get(params.key);
154+
const text = found
155+
? `Value for key "${params.key}": ${JSON.stringify(value)}`
156+
: `Key "${params.key}" not found in store.`;
157+
return {
158+
content: [{ type: "text", text }],
159+
details: { key: params.key, value: found ? value : null, found },
160+
};
161+
},
162+
}) as unknown as ToolDefinition;
163+
164+
return [storePut, storeGet];
165+
}
166+
167+
/**
168+
* Create per-agent store tools that attribute writes to `deltaKey`, a
169+
* run-unique `${runId}:${callIndex}` string (see the `SharedStore` class doc
170+
* for why the bare callIndex alone is not enough once a nested `workflow()`
171+
* call shares this store).
172+
* Used internally by `runWorkflow` so each agent's puts are tracked in the
173+
* store's delta journal and can be replayed additively on resume.
174+
*/
175+
export function createAgentStoreTools(store: SharedStore, deltaKey: string): ToolDefinition[] {
176+
const storePut = defineTool({
177+
name: "store_put",
178+
label: "Store Put",
179+
description:
180+
"Write a value to the shared run store. Any other agent in this workflow run can read it with store_get. Overwrites any existing value for the key. Note: when two parallel agents write the same key, the last write wins — no merge is performed.",
181+
promptSnippet: "Write a value to the shared store",
182+
parameters: Type.Object({
183+
key: Type.String({ description: "The key to store the value under." }),
184+
value: Type.Any({ description: "The value to store (any JSON-serializable value)." }),
185+
}),
186+
async execute(_id: string, params: { key: string; value: unknown }) {
187+
store.trackPut(params.key, params.value, deltaKey);
188+
return {
189+
content: [{ type: "text", text: `Stored value under key "${params.key}".` }],
190+
details: { key: params.key },
191+
};
192+
},
193+
}) as unknown as ToolDefinition;
194+
195+
const storeGet = defineTool({
196+
name: "store_get",
197+
label: "Store Get",
198+
description:
199+
"Read a value from the shared run store previously written by store_put. Returns the stored value, or null when the key does not exist.",
200+
promptSnippet: "Read a value from the shared store",
201+
parameters: Type.Object({
202+
key: Type.String({ description: "The key to read." }),
203+
}),
204+
async execute(_id: string, params: { key: string }) {
205+
const found = store.has(params.key);
206+
const value = store.get(params.key);
207+
const text = found
208+
? `Value for key "${params.key}": ${JSON.stringify(value)}`
209+
: `Key "${params.key}" not found in store.`;
210+
return {
211+
content: [{ type: "text", text }],
212+
details: { key: params.key, value: found ? value : null, found },
213+
};
214+
},
215+
}) as unknown as ToolDefinition;
216+
217+
return [storePut, storeGet];
218+
}

0 commit comments

Comments
 (0)