@fungi.computer/shiitake-extensions 0.3.0 → 0.4.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 +18 -13
- package/dist/goal/contract.d.ts +6 -0
- package/dist/goal/contract.js +6 -0
- package/dist/goal/host.js +180 -30
- package/dist/question/answer-flow.d.ts +27 -0
- package/dist/question/answer-flow.js +115 -0
- package/dist/question/client.d.ts +1 -1
- package/dist/question/client.js +22 -3
- package/dist/question/contract.d.ts +202 -60
- package/dist/question/contract.js +78 -22
- package/dist/question/host.d.ts +4 -0
- package/dist/question/host.js +348 -339
- package/dist/roster/conversation-agents.d.ts +12 -1
- package/dist/roster/conversation-agents.js +37 -2
- package/dist/roster.d.ts +5 -2
- package/dist/roster.js +16 -5
- package/dist/subagents/client.js +64 -20
- package/dist/subagents/contract.d.ts +123 -10
- package/dist/subagents/contract.js +38 -5
- package/dist/subagents/host.d.ts +2 -4
- package/dist/subagents/host.js +152 -22
- package/docs/api/goal/host.md +22 -1
- package/docs/api/question/client.md +2 -1
- package/docs/api/question/contract.md +183 -51
- package/docs/api/question/host.md +30 -0
- package/docs/api/roster/conversation-agents.md +15 -7
- package/docs/api/roster.md +39 -2
- package/docs/api/subagents/contract.md +101 -28
- package/docs/api/subagents/host.md +5 -2
- package/package.json +5 -6
package/dist/subagents/host.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { defineExtension, } from "@fungi.computer/shiitake/extensions";
|
|
2
2
|
import * as Effect from "effect/Effect";
|
|
3
3
|
import * as Option from "effect/Option";
|
|
4
|
+
import * as Schema from "effect/Schema";
|
|
4
5
|
import { catalog, done } from "../internal/catalog.js";
|
|
6
|
+
import { advanceAnswerFlow } from "../question/answer-flow.js";
|
|
7
|
+
import { AskProjection, questionContract } from "../question/contract.js";
|
|
5
8
|
import { maximumSubagents, subagentsContract, } from "./contract.js";
|
|
6
9
|
const defaultTiming = Object.freeze({
|
|
7
10
|
refreshMs: 1_000,
|
|
8
11
|
pollMs: 5_000,
|
|
9
|
-
interestMs: 30_000,
|
|
10
12
|
});
|
|
11
|
-
/** Sessions
|
|
13
|
+
/** Sessions held at once; evict unwatched rows first, then least recently read. */
|
|
12
14
|
const cachedSessions = 64;
|
|
13
15
|
/** Owner pages scanned for one child's row: 400 conversations. */
|
|
14
16
|
const statusPages = 2;
|
|
@@ -19,7 +21,7 @@ export const subagentsGrant = {
|
|
|
19
21
|
};
|
|
20
22
|
const settledState = {
|
|
21
23
|
running: "working",
|
|
22
|
-
needs_you: "
|
|
24
|
+
needs_you: "needs_you",
|
|
23
25
|
done: "idle",
|
|
24
26
|
errored: "failed",
|
|
25
27
|
};
|
|
@@ -56,15 +58,44 @@ async function status(source, record, signal) {
|
|
|
56
58
|
if (row === undefined || row.state === "unavailable")
|
|
57
59
|
return { ...base, state: "unavailable" };
|
|
58
60
|
const selected = { ...base, sessionId: row.sessionId };
|
|
59
|
-
if (row.state
|
|
61
|
+
if (row.state === "running" || row.state === "errored")
|
|
60
62
|
return { ...selected, state: settledState[row.state] };
|
|
63
|
+
// An item waiting on an Agent is calm and leaves the inbox state alone,
|
|
64
|
+
// so a finished child is read for it too. Escalated items wait on a person.
|
|
61
65
|
const snapshot = await opened.agent
|
|
62
66
|
.session(row.sessionId)
|
|
63
67
|
.read({ limit: 1 });
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
?
|
|
67
|
-
:
|
|
68
|
+
const slot = snapshot.extensions[questionContract.id];
|
|
69
|
+
const asked = slot?.status === "running"
|
|
70
|
+
? Schema.decodeUnknownOption(AskProjection)(slot.value)
|
|
71
|
+
: Option.none();
|
|
72
|
+
const openAsks = Option.isSome(asked) ? asked.value.asks : [];
|
|
73
|
+
const routed = snapshot.attention.find((item) => item.waitingOn);
|
|
74
|
+
if (routed?.waitingOn !== undefined) {
|
|
75
|
+
const waiting = {
|
|
76
|
+
...selected,
|
|
77
|
+
state: "waiting_on_agent",
|
|
78
|
+
attention: routed.summary,
|
|
79
|
+
waitingOn: routed.waitingOn.name,
|
|
80
|
+
};
|
|
81
|
+
// A person may always answer first; the calm row offers it too.
|
|
82
|
+
const routedAsk = openAsks.find((open) => open.askId === routed.id);
|
|
83
|
+
return routedAsk === undefined
|
|
84
|
+
? waiting
|
|
85
|
+
: {
|
|
86
|
+
...waiting,
|
|
87
|
+
ask: { askId: routedAsk.askId, questions: routedAsk.questions },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const attention = snapshot.attention.find((item) => item.inbox !== false)?.summary;
|
|
91
|
+
if (row.state === "done")
|
|
92
|
+
return { ...selected, state: "idle" };
|
|
93
|
+
const ask = openAsks.find((open) => open.waitingOn === undefined);
|
|
94
|
+
const needing = { ...selected, state: "needs_you" };
|
|
95
|
+
const summary = attention === undefined ? needing : { ...needing, attention };
|
|
96
|
+
return ask === undefined
|
|
97
|
+
? summary
|
|
98
|
+
: { ...summary, ask: { askId: ask.askId, questions: ask.questions } };
|
|
68
99
|
}
|
|
69
100
|
finally {
|
|
70
101
|
await opened.release();
|
|
@@ -76,19 +107,34 @@ async function status(source, record, signal) {
|
|
|
76
107
|
return { ...base, state: "unavailable" };
|
|
77
108
|
}
|
|
78
109
|
}
|
|
79
|
-
const live = (value) => value?.agents.some((agent) => agent.state === "working" ||
|
|
110
|
+
const live = (value) => value?.agents.some((agent) => agent.state === "working" ||
|
|
111
|
+
agent.state === "waiting_on_agent" ||
|
|
112
|
+
agent.state === "needs_you") === true;
|
|
80
113
|
/**
|
|
81
114
|
* One activation's in-memory rows. A watcher's projection read refreshes
|
|
82
|
-
* stale rows outside the snapshot transaction
|
|
83
|
-
*
|
|
115
|
+
* stale rows outside the snapshot transaction. Only changed rows publish;
|
|
116
|
+
* only an actual live-view registration keeps polling alive.
|
|
84
117
|
*/
|
|
85
|
-
function createRoster(source, timing, publish) {
|
|
118
|
+
function createRoster(source, timing, publish, isObserved) {
|
|
86
119
|
const entries = new Map();
|
|
87
120
|
const lifetime = new AbortController();
|
|
88
121
|
function forget(sessionId) {
|
|
89
122
|
clearTimeout(entries.get(sessionId)?.timer);
|
|
90
123
|
entries.delete(sessionId);
|
|
91
124
|
}
|
|
125
|
+
function schedule(sessionId, entry) {
|
|
126
|
+
if (!lifetime.signal.aborted &&
|
|
127
|
+
entries.get(sessionId) === entry &&
|
|
128
|
+
entry.timer === undefined &&
|
|
129
|
+
!entry.refreshing &&
|
|
130
|
+
live(entry.value) &&
|
|
131
|
+
isObserved(sessionId))
|
|
132
|
+
entry.timer = setTimeout(() => {
|
|
133
|
+
entry.timer = undefined;
|
|
134
|
+
if (isObserved(sessionId))
|
|
135
|
+
void refresh(sessionId, entry);
|
|
136
|
+
}, timing.pollMs);
|
|
137
|
+
}
|
|
92
138
|
async function refresh(sessionId, entry) {
|
|
93
139
|
entry.refreshing = true;
|
|
94
140
|
clearTimeout(entry.timer);
|
|
@@ -103,7 +149,7 @@ function createRoster(source, timing, publish) {
|
|
|
103
149
|
};
|
|
104
150
|
const changed = JSON.stringify(value) !== JSON.stringify(entry.value);
|
|
105
151
|
entry.value = value;
|
|
106
|
-
if (!lifetime.signal.aborted &&
|
|
152
|
+
if (!lifetime.signal.aborted && changed)
|
|
107
153
|
await publish(sessionId);
|
|
108
154
|
}
|
|
109
155
|
catch (cause) {
|
|
@@ -113,13 +159,7 @@ function createRoster(source, timing, publish) {
|
|
|
113
159
|
entry.refreshedAt = Date.now();
|
|
114
160
|
entry.refreshing = false;
|
|
115
161
|
}
|
|
116
|
-
|
|
117
|
-
entries.get(sessionId) === entry &&
|
|
118
|
-
live(entry.value) &&
|
|
119
|
-
Date.now() - entry.readAt < timing.interestMs)
|
|
120
|
-
entry.timer = setTimeout(() => {
|
|
121
|
-
void refresh(sessionId, entry);
|
|
122
|
-
}, timing.pollMs);
|
|
162
|
+
schedule(sessionId, entry);
|
|
123
163
|
}
|
|
124
164
|
return {
|
|
125
165
|
/** Flip whether the footer lists the rows; undefined while there is nothing to list. */
|
|
@@ -144,7 +184,8 @@ function createRoster(source, timing, publish) {
|
|
|
144
184
|
};
|
|
145
185
|
entries.set(sessionId, entry);
|
|
146
186
|
if (entries.size > cachedSessions) {
|
|
147
|
-
const oldest = [...entries].sort(([, left], [, right]) =>
|
|
187
|
+
const oldest = [...entries].sort(([leftId, left], [rightId, right]) => Number(isObserved(leftId)) - Number(isObserved(rightId)) ||
|
|
188
|
+
left.readAt - right.readAt)[0];
|
|
148
189
|
if (oldest !== undefined)
|
|
149
190
|
forget(oldest[0]);
|
|
150
191
|
}
|
|
@@ -152,10 +193,46 @@ function createRoster(source, timing, publish) {
|
|
|
152
193
|
entry.readAt = now;
|
|
153
194
|
if (!entry.refreshing && now - entry.refreshedAt >= timing.refreshMs)
|
|
154
195
|
void refresh(sessionId, entry);
|
|
196
|
+
else
|
|
197
|
+
schedule(sessionId, entry);
|
|
155
198
|
return entry.value === undefined
|
|
156
199
|
? undefined
|
|
157
200
|
: { ...entry.value, expanded: entry.expanded };
|
|
158
201
|
},
|
|
202
|
+
/** The last read of one child of this conversation, if it is listed. */
|
|
203
|
+
child(sessionId, agentId, childSessionId) {
|
|
204
|
+
return entries
|
|
205
|
+
.get(sessionId)
|
|
206
|
+
?.value?.agents.find((agent) => agent.agentId === agentId && agent.sessionId === childSessionId);
|
|
207
|
+
},
|
|
208
|
+
/**
|
|
209
|
+
* Hand a person's completed answer to the child's own question owner. The
|
|
210
|
+
* command that asked returns first, so the answer is sent outside it; a
|
|
211
|
+
* failure is logged and the row stays, so the person can answer again.
|
|
212
|
+
*/
|
|
213
|
+
submit(sessionId, target) {
|
|
214
|
+
void (async () => {
|
|
215
|
+
const opened = await source.open(target.agentId, lifetime.signal);
|
|
216
|
+
try {
|
|
217
|
+
await opened.agent
|
|
218
|
+
.session(target.sessionId)
|
|
219
|
+
.extension(questionContract.id)
|
|
220
|
+
.call("answer", {
|
|
221
|
+
askId: target.askId,
|
|
222
|
+
requestId: target.requestId,
|
|
223
|
+
answers: target.answers,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
await opened.release();
|
|
228
|
+
}
|
|
229
|
+
const entry = entries.get(sessionId);
|
|
230
|
+
if (entry !== undefined && !entry.refreshing)
|
|
231
|
+
await refresh(sessionId, entry);
|
|
232
|
+
})().catch((cause) => {
|
|
233
|
+
Effect.runFork(Effect.logError("subagents.answer", cause));
|
|
234
|
+
});
|
|
235
|
+
},
|
|
159
236
|
close() {
|
|
160
237
|
lifetime.abort();
|
|
161
238
|
for (const sessionId of entries.keys())
|
|
@@ -177,7 +254,7 @@ export function subagentsExtension(options) {
|
|
|
177
254
|
.define({
|
|
178
255
|
hooks: {
|
|
179
256
|
start: (ctx) => Effect.acquireRelease(Effect.sync(() => {
|
|
180
|
-
const roster = createRoster(options.source, options.timing ?? defaultTiming, (sessionId) => Effect.runPromise(ctx.transact(ctx.observe(sessionId)).pipe(Effect.ignore)));
|
|
257
|
+
const roster = createRoster(options.source, options.timing ?? defaultTiming, (sessionId) => Effect.runPromise(ctx.transact(ctx.observe(sessionId)).pipe(Effect.ignore)), ctx.isObserved);
|
|
181
258
|
rosters.set(ctx, roster);
|
|
182
259
|
return roster;
|
|
183
260
|
}), (roster) => Effect.sync(() => {
|
|
@@ -197,6 +274,13 @@ export function subagentsExtension(options) {
|
|
|
197
274
|
slash: { aliases: ["agents"], order: 6 },
|
|
198
275
|
},
|
|
199
276
|
},
|
|
277
|
+
{
|
|
278
|
+
// Reached only from a sub-agent row's Answer action.
|
|
279
|
+
id: "answerChild",
|
|
280
|
+
title: "Answer a sub-agent",
|
|
281
|
+
category: "Conversation",
|
|
282
|
+
description: "Answer a question a sub-agent has escalated to you",
|
|
283
|
+
},
|
|
200
284
|
]),
|
|
201
285
|
projection: {
|
|
202
286
|
schema: subagentsContract.projection.schema,
|
|
@@ -216,6 +300,52 @@ export function subagentsExtension(options) {
|
|
|
216
300
|
// The changed rows already show the result; don't echo the toggle.
|
|
217
301
|
return { type: "done" };
|
|
218
302
|
}),
|
|
303
|
+
answerChild: ({ sessionId, input }) => Effect.gen(function* () {
|
|
304
|
+
const roster = rosters.get(ctx);
|
|
305
|
+
const row = roster?.child(sessionId, input.agentId, input.sessionId);
|
|
306
|
+
if (roster === undefined || row?.ask === undefined)
|
|
307
|
+
return done({
|
|
308
|
+
type: "info",
|
|
309
|
+
message: "That question is no longer waiting.",
|
|
310
|
+
});
|
|
311
|
+
const outcome = yield* advanceAnswerFlow({ ...row.ask, closed: false }, input);
|
|
312
|
+
if (outcome.type === "complete") {
|
|
313
|
+
roster.submit(sessionId, {
|
|
314
|
+
agentId: input.agentId,
|
|
315
|
+
sessionId: input.sessionId,
|
|
316
|
+
askId: outcome.askId,
|
|
317
|
+
requestId: outcome.requestId,
|
|
318
|
+
answers: outcome.answers,
|
|
319
|
+
});
|
|
320
|
+
return done({
|
|
321
|
+
type: "success",
|
|
322
|
+
// The child's owner commits it; an answer that loses the race changes nothing.
|
|
323
|
+
message: `Sending your answer to ${row.name}.`,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
const step = outcome.step;
|
|
327
|
+
// Each prompt continues here, naming the child it is for, and
|
|
328
|
+
// says whose question it is: the composer shows only the prompt.
|
|
329
|
+
return step.type === "prompt"
|
|
330
|
+
? {
|
|
331
|
+
...step,
|
|
332
|
+
prompt: {
|
|
333
|
+
...step.prompt,
|
|
334
|
+
summary: step.prompt.summary === undefined
|
|
335
|
+
? `${row.name} asks`
|
|
336
|
+
: `${row.name} asks · ${step.prompt.summary}`,
|
|
337
|
+
},
|
|
338
|
+
next: {
|
|
339
|
+
...step.next,
|
|
340
|
+
arguments: {
|
|
341
|
+
...step.next.arguments,
|
|
342
|
+
agentId: input.agentId,
|
|
343
|
+
sessionId: input.sessionId,
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
}
|
|
347
|
+
: step;
|
|
348
|
+
}),
|
|
219
349
|
}),
|
|
220
350
|
},
|
|
221
351
|
});
|
package/docs/api/goal/host.md
CHANGED
|
@@ -26,4 +26,25 @@ The commands this extension advertises to every client.
|
|
|
26
26
|
const goalExtension: RosteredExtensionV0;
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
Ordinary roster when no created-Agent directory is available.
|
|
30
|
+
|
|
31
|
+
## Functions
|
|
32
|
+
|
|
33
|
+
### goalExtensionFor()
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
function goalExtensionFor(subagents?): RosteredExtensionV0;
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Botanical's Session goal; hosts with created-Agent facts include them in
|
|
40
|
+
progress.
|
|
41
|
+
|
|
42
|
+
#### Parameters
|
|
43
|
+
|
|
44
|
+
| Parameter | Type |
|
|
45
|
+
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
46
|
+
| `subagents?` | `Readonly`\<\{ `hasLiveChildCommand`: (`sessionId`) => `boolean`; `read`: (`sessionId`, `limit`) => `Promise`\<`Readonly`\<\{ `agents`: readonly `Readonly`\<\{ `agentId`: `string`; `name`: `string`; `sessionId`: ... \| ...; \}\>[]; `more`: `number`; \}\>\>; `open`: (`agentId`, `signal`) => `Promise`\<`Readonly`\<\{ `agent`: [`SubagentAgent`](../subagents/host.md#subagentagent); `release`: () => `Promise`\<`void`\>; \}\>\>; \}\> |
|
|
47
|
+
|
|
48
|
+
#### Returns
|
|
49
|
+
|
|
50
|
+
`RosteredExtensionV0`
|