@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.
@@ -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 whose rows are held at once; the least recently read go first. */
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: "waiting",
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 !== "needs_you")
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 attention = snapshot.attention[0]?.summary;
65
- return attention === undefined
66
- ? { ...selected, state: "waiting" }
67
- : { ...selected, state: "waiting", attention };
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" || agent.state === "waiting") === true;
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; a changed or still-live result
83
- * publishes one observation, whose re-read keeps interest alive.
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 && (changed || live(value)))
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
- if (!lifetime.signal.aborted &&
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]) => left.readAt - right.readAt)[0];
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
  });
@@ -26,4 +26,25 @@ The commands this extension advertises to every client.
26
26
  const goalExtension: RosteredExtensionV0;
27
27
  ```
28
28
 
29
- Botanical's Session goal, driven only by settled runs.
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`
@@ -22,4 +22,5 @@ default: Readonly<{
22
22
  }>;
23
23
  ```
24
24
 
25
- Pending decisions use the existing footer rows and advertised answer flow.
25
+ Pending decisions take over the composer; the footer row is where a postponed
26
+ one waits.