@fungi.computer/shiitake-extensions 0.3.0 → 0.5.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.
Files changed (39) hide show
  1. package/README.md +20 -16
  2. package/dist/goal/contract.d.ts +6 -0
  3. package/dist/goal/contract.js +6 -0
  4. package/dist/goal/host.js +183 -31
  5. package/dist/loop/contract.d.ts +136 -2
  6. package/dist/loop/contract.js +45 -2
  7. package/dist/loop/host.js +23 -0
  8. package/dist/question/answer-flow.d.ts +27 -0
  9. package/dist/question/answer-flow.js +115 -0
  10. package/dist/question/client.d.ts +1 -1
  11. package/dist/question/client.js +22 -3
  12. package/dist/question/contract.d.ts +202 -60
  13. package/dist/question/contract.js +78 -22
  14. package/dist/question/host.d.ts +4 -0
  15. package/dist/question/host.js +348 -339
  16. package/dist/roster/conversation-agents.d.ts +21 -1
  17. package/dist/roster/conversation-agents.js +47 -2
  18. package/dist/roster.d.ts +5 -2
  19. package/dist/roster.js +16 -5
  20. package/dist/schedules.d.ts +178 -0
  21. package/dist/schedules.js +182 -0
  22. package/dist/subagents/client.js +64 -20
  23. package/dist/subagents/contract.d.ts +123 -10
  24. package/dist/subagents/contract.js +38 -5
  25. package/dist/subagents/host.d.ts +2 -4
  26. package/dist/subagents/host.js +152 -22
  27. package/docs/api/README.md +1 -0
  28. package/docs/api/goal/contract.md +1 -1
  29. package/docs/api/goal/host.md +22 -1
  30. package/docs/api/loop/contract.md +43 -31
  31. package/docs/api/question/client.md +2 -1
  32. package/docs/api/question/contract.md +183 -51
  33. package/docs/api/question/host.md +30 -0
  34. package/docs/api/roster/conversation-agents.md +34 -8
  35. package/docs/api/roster.md +39 -2
  36. package/docs/api/schedules.md +481 -0
  37. package/docs/api/subagents/contract.md +101 -28
  38. package/docs/api/subagents/host.md +5 -3
  39. package/package.json +9 -6
@@ -2,11 +2,12 @@ import * as Schema from "effect/Schema";
2
2
  /** The most Agents one conversation's footer lists; the rest are counted. */
3
3
  export declare const maximumSubagents = 16;
4
4
  /**
5
- * What the human sees for one ordinary created Agent. `waiting` is any open attention on
6
- * its conversation (a question or a tool approval); `unavailable` means its
7
- * status could not be read, and is never shown as idle.
5
+ * What the human sees for one ordinary created Agent. `waiting_on_agent` is an
6
+ * open question that waits on the Agent that sent the work, calm until it is
7
+ * escalated; `needs_you` is attention that waits on a person; `unavailable`
8
+ * means its status could not be read, and is never shown as idle.
8
9
  */
9
- export declare const SubagentState: Schema.Literals<readonly ["working", "waiting", "idle", "failed", "unavailable"]>;
10
+ export declare const SubagentState: Schema.Literals<readonly ["working", "waiting_on_agent", "needs_you", "idle", "failed", "unavailable"]>;
10
11
  /** Display state of one created Agent's conversation. */
11
12
  export type SubagentState = typeof SubagentState.Type;
12
13
  /** A footer row for an ordinary Agent created by this Agent, never merely sent work. */
@@ -18,9 +19,27 @@ export declare const Subagent: Schema.Struct<{
18
19
  /** Its latest conversation; null when it has none. */
19
20
  readonly sessionId: Schema.NullOr<Schema.String>;
20
21
  /** Observed conversation state, including unreadable status. */
21
- readonly state: Schema.Literals<readonly ["working", "waiting", "idle", "failed", "unavailable"]>;
22
- /** The child's own summary of what waits on the human, when waiting. */
22
+ readonly state: Schema.Literals<readonly ["working", "waiting_on_agent", "needs_you", "idle", "failed", "unavailable"]>;
23
+ /** The child's own summary of what it waits on, when waiting. */
23
24
  readonly attention: Schema.optionalKey<Schema.String>;
25
+ /** Display name of the Agent a `waiting_on_agent` child waits on. */
26
+ readonly waitingOn: Schema.optionalKey<Schema.String>;
27
+ /** The question a waiting child asks, so a person can answer from here. */
28
+ readonly ask: Schema.optionalKey<Schema.Struct<{
29
+ /** Durable identity of the child's open Ask. */
30
+ readonly askId: Schema.String;
31
+ /** The decisions the child asks, so they can be answered from here. */
32
+ readonly questions: Schema.$Array<Schema.Struct<{
33
+ readonly id: Schema.String;
34
+ readonly header: Schema.String;
35
+ readonly question: Schema.String;
36
+ readonly options: Schema.$Array<Schema.Struct<{
37
+ readonly label: Schema.String;
38
+ readonly description: Schema.String;
39
+ }>>;
40
+ readonly multiple: Schema.optionalKey<Schema.Boolean>;
41
+ }>>;
42
+ }>>;
24
43
  }>;
25
44
  /** Decoded footer row for a created Agent. */
26
45
  export type Subagent = typeof Subagent.Type;
@@ -39,9 +58,27 @@ export declare const SubagentsProjection: Schema.Struct<{
39
58
  /** Its latest conversation; null when it has none. */
40
59
  readonly sessionId: Schema.NullOr<Schema.String>;
41
60
  /** Observed conversation state, including unreadable status. */
42
- readonly state: Schema.Literals<readonly ["working", "waiting", "idle", "failed", "unavailable"]>;
43
- /** The child's own summary of what waits on the human, when waiting. */
61
+ readonly state: Schema.Literals<readonly ["working", "waiting_on_agent", "needs_you", "idle", "failed", "unavailable"]>;
62
+ /** The child's own summary of what it waits on, when waiting. */
44
63
  readonly attention: Schema.optionalKey<Schema.String>;
64
+ /** Display name of the Agent a `waiting_on_agent` child waits on. */
65
+ readonly waitingOn: Schema.optionalKey<Schema.String>;
66
+ /** The question a waiting child asks, so a person can answer from here. */
67
+ readonly ask: Schema.optionalKey<Schema.Struct<{
68
+ /** Durable identity of the child's open Ask. */
69
+ readonly askId: Schema.String;
70
+ /** The decisions the child asks, so they can be answered from here. */
71
+ readonly questions: Schema.$Array<Schema.Struct<{
72
+ readonly id: Schema.String;
73
+ readonly header: Schema.String;
74
+ readonly question: Schema.String;
75
+ readonly options: Schema.$Array<Schema.Struct<{
76
+ readonly label: Schema.String;
77
+ readonly description: Schema.String;
78
+ }>>;
79
+ readonly multiple: Schema.optionalKey<Schema.Boolean>;
80
+ }>>;
81
+ }>>;
45
82
  }>>;
46
83
  /** Additional created Agents not included in the visible rows. */
47
84
  readonly more: Schema.Int;
@@ -50,6 +87,33 @@ export declare const SubagentsProjection: Schema.Struct<{
50
87
  }>;
51
88
  /** Decoded list and expansion state projected into the footer. */
52
89
  export type SubagentsProjection = typeof SubagentsProjection.Type;
90
+ /**
91
+ * The question flow's own steps, each naming the child whose Ask they answer.
92
+ * The footer owns the conversation; the child's question owner commits it.
93
+ */
94
+ export declare const AnswerChildInput: Schema.Union<Schema.Struct<{
95
+ readonly agentId: Schema.String;
96
+ readonly sessionId: Schema.String;
97
+ readonly answer: Schema.Struct<{
98
+ readonly selected: Schema.$Array<Schema.String>;
99
+ readonly text: Schema.optionalKey<Schema.String>;
100
+ }>;
101
+ readonly askId: Schema.String;
102
+ readonly requestId: Schema.String;
103
+ readonly index: Schema.Number;
104
+ readonly answers: Schema.$Record<Schema.String, Schema.Struct<{
105
+ readonly selected: Schema.$Array<Schema.String>;
106
+ readonly text: Schema.optionalKey<Schema.String>;
107
+ }>>;
108
+ readonly stage: Schema.Literal<"decision">;
109
+ } | {
110
+ readonly agentId: Schema.String;
111
+ readonly sessionId: Schema.String;
112
+ readonly askId: Schema.String;
113
+ } | {
114
+ readonly agentId: Schema.String;
115
+ readonly sessionId: Schema.String;
116
+ }>[]>;
53
117
  /** Created-Agent footer control and its visible projection. */
54
118
  export declare const subagentsContract: {
55
119
  /** Stable extension identity. */
@@ -65,6 +129,37 @@ export declare const subagentsContract: {
65
129
  /** Return the server-owned human control step. */
66
130
  output: Schema.declare<import("@fungi.computer/whistle/catalog").WhistleCatalogStepV1, import("@fungi.computer/whistle/catalog").WhistleCatalogStepV1>;
67
131
  };
132
+ /** Answer a question an escalated sub-agent asks, from this conversation. */
133
+ answerChild: {
134
+ /** The Session owns its footer. */
135
+ scope: "session";
136
+ /** The next step of the child's answer conversation. */
137
+ input: Schema.Union<Schema.Struct<{
138
+ readonly agentId: Schema.String;
139
+ readonly sessionId: Schema.String;
140
+ readonly answer: Schema.Struct<{
141
+ readonly selected: Schema.$Array<Schema.String>;
142
+ readonly text: Schema.optionalKey<Schema.String>;
143
+ }>;
144
+ readonly askId: Schema.String;
145
+ readonly requestId: Schema.String;
146
+ readonly index: Schema.Number;
147
+ readonly answers: Schema.$Record<Schema.String, Schema.Struct<{
148
+ readonly selected: Schema.$Array<Schema.String>;
149
+ readonly text: Schema.optionalKey<Schema.String>;
150
+ }>>;
151
+ readonly stage: Schema.Literal<"decision">;
152
+ } | {
153
+ readonly agentId: Schema.String;
154
+ readonly sessionId: Schema.String;
155
+ readonly askId: Schema.String;
156
+ } | {
157
+ readonly agentId: Schema.String;
158
+ readonly sessionId: Schema.String;
159
+ }>[]>;
160
+ /** Return the next human conversation step. */
161
+ output: Schema.declare<import("@fungi.computer/whistle/catalog").WhistleCatalogStepV1, import("@fungi.computer/whistle/catalog").WhistleCatalogStepV1>;
162
+ };
68
163
  };
69
164
  /** Footer rows and expansion state. */
70
165
  readonly projection: {
@@ -79,9 +174,27 @@ export declare const subagentsContract: {
79
174
  /** Its latest conversation; null when it has none. */
80
175
  readonly sessionId: Schema.NullOr<Schema.String>;
81
176
  /** Observed conversation state, including unreadable status. */
82
- readonly state: Schema.Literals<readonly ["working", "waiting", "idle", "failed", "unavailable"]>;
83
- /** The child's own summary of what waits on the human, when waiting. */
177
+ readonly state: Schema.Literals<readonly ["working", "waiting_on_agent", "needs_you", "idle", "failed", "unavailable"]>;
178
+ /** The child's own summary of what it waits on, when waiting. */
84
179
  readonly attention: Schema.optionalKey<Schema.String>;
180
+ /** Display name of the Agent a `waiting_on_agent` child waits on. */
181
+ readonly waitingOn: Schema.optionalKey<Schema.String>;
182
+ /** The question a waiting child asks, so a person can answer from here. */
183
+ readonly ask: Schema.optionalKey<Schema.Struct<{
184
+ /** Durable identity of the child's open Ask. */
185
+ readonly askId: Schema.String;
186
+ /** The decisions the child asks, so they can be answered from here. */
187
+ readonly questions: Schema.$Array<Schema.Struct<{
188
+ readonly id: Schema.String;
189
+ readonly header: Schema.String;
190
+ readonly question: Schema.String;
191
+ readonly options: Schema.$Array<Schema.Struct<{
192
+ readonly label: Schema.String;
193
+ readonly description: Schema.String;
194
+ }>>;
195
+ readonly multiple: Schema.optionalKey<Schema.Boolean>;
196
+ }>>;
197
+ }>>;
85
198
  }>>;
86
199
  /** Additional created Agents not included in the visible rows. */
87
200
  readonly more: Schema.Int;
@@ -1,17 +1,20 @@
1
1
  import { ExtensionId, } from "@fungi.computer/shiitake/extensions";
2
2
  import * as Schema from "effect/Schema";
3
3
  import { CatalogStep } from "../internal/catalog.js";
4
+ import { AnswerFlowInput, Questions } from "../question/contract.js";
4
5
  const identity = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256));
5
6
  /** The most Agents one conversation's footer lists; the rest are counted. */
6
7
  export const maximumSubagents = 16;
7
8
  /**
8
- * What the human sees for one ordinary created Agent. `waiting` is any open attention on
9
- * its conversation (a question or a tool approval); `unavailable` means its
10
- * status could not be read, and is never shown as idle.
9
+ * What the human sees for one ordinary created Agent. `waiting_on_agent` is an
10
+ * open question that waits on the Agent that sent the work, calm until it is
11
+ * escalated; `needs_you` is attention that waits on a person; `unavailable`
12
+ * means its status could not be read, and is never shown as idle.
11
13
  */
12
14
  export const SubagentState = Schema.Literals([
13
15
  "working",
14
- "waiting",
16
+ "waiting_on_agent",
17
+ "needs_you",
15
18
  "idle",
16
19
  "failed",
17
20
  "unavailable",
@@ -26,8 +29,17 @@ export const Subagent = Schema.Struct({
26
29
  sessionId: Schema.NullOr(identity),
27
30
  /** Observed conversation state, including unreadable status. */
28
31
  state: SubagentState,
29
- /** The child's own summary of what waits on the human, when waiting. */
32
+ /** The child's own summary of what it waits on, when waiting. */
30
33
  attention: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(200))),
34
+ /** Display name of the Agent a `waiting_on_agent` child waits on. */
35
+ waitingOn: Schema.optionalKey(identity),
36
+ /** The question a waiting child asks, so a person can answer from here. */
37
+ ask: Schema.optionalKey(Schema.Struct({
38
+ /** Durable identity of the child's open Ask. */
39
+ askId: identity,
40
+ /** The decisions the child asks, so they can be answered from here. */
41
+ questions: Questions,
42
+ })),
31
43
  });
32
44
  /**
33
45
  * This Agent's created Agents and how many are not listed.
@@ -42,6 +54,18 @@ export const SubagentsProjection = Schema.Struct({
42
54
  /** Whether individual rows are expanded instead of summarized. */
43
55
  expanded: Schema.Boolean,
44
56
  });
57
+ /** One child, as an answer step names it beside the draft it advances. */
58
+ const child = {
59
+ /** Identity of the child Agent whose Ask is being answered. */
60
+ agentId: identity,
61
+ /** The child's conversation that holds the Ask. */
62
+ sessionId: identity,
63
+ };
64
+ /**
65
+ * The question flow's own steps, each naming the child whose Ask they answer.
66
+ * The footer owns the conversation; the child's question owner commits it.
67
+ */
68
+ export const AnswerChildInput = Schema.Union(AnswerFlowInput.members.map((member) => Schema.Struct({ ...member.fields, ...child })));
45
69
  /** The human `/agents` command: a bare call expands or collapses the footer rows. */
46
70
  const commands = {
47
71
  /** Toggle the created-Agent footer between expanded and summarized views. */
@@ -53,6 +77,15 @@ const commands = {
53
77
  /** Return the server-owned human control step. */
54
78
  output: CatalogStep,
55
79
  },
80
+ /** Answer a question an escalated sub-agent asks, from this conversation. */
81
+ answerChild: {
82
+ /** The Session owns its footer. */
83
+ scope: "session",
84
+ /** The next step of the child's answer conversation. */
85
+ input: AnswerChildInput,
86
+ /** Return the next human conversation step. */
87
+ output: CatalogStep,
88
+ },
56
89
  };
57
90
  /** Created-Agent footer control and its visible projection. */
58
91
  export const subagentsContract = {
@@ -14,7 +14,7 @@ export type SubagentAgent = Readonly<{
14
14
  /** Read the child's owner-ordered conversation summaries. */
15
15
  listSessions: ShiitakeAgent["listSessions"];
16
16
  /** Open the read-only view of a selected child conversation. */
17
- session: (sessionId: string) => Pick<ShiitakeSession, "read" | "readSettlement">;
17
+ session: (sessionId: string) => Pick<ShiitakeSession, "read" | "readSettlement" | "extension">;
18
18
  }>;
19
19
  /**
20
20
  * The host's read-only facts. `read` returns ordinary created Agents,
@@ -44,10 +44,8 @@ export type SubagentSource = Readonly<{
44
44
  export type SubagentTiming = Readonly<{
45
45
  /** A watcher's read refreshes rows at least this old. */
46
46
  refreshMs: number;
47
- /** While a row is working or waiting, rows refresh this often. */
47
+ /** While a live view watches a working or waiting row, refresh this often. */
48
48
  pollMs: number;
49
- /** Polling stops once no watcher has read the Session this long. */
50
- interestMs: number;
51
49
  }>;
52
50
  /** Host admission for the read-only created-Agent footer and human toggle. */
53
51
  export declare const subagentsGrant: ExtensionGrantV0;
@@ -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
  });
@@ -28,6 +28,7 @@
28
28
  | [question/host](question/host.md) | - |
29
29
  | [roster](roster.md) | - |
30
30
  | [roster/conversation-agents](roster/conversation-agents.md) | - |
31
+ | [schedules](schedules.md) | - |
31
32
  | [skills/host](skills/host.md) | - |
32
33
  | [skills/source](skills/source.md) | - |
33
34
  | [subagents/client](subagents/client.md) | - |
@@ -244,7 +244,7 @@ Session-owned goal controls and their visible projection.
244
244
  | `commands.clear.output` | `Struct`\<\{ \}\> | - | A successful clear has no result fields. |
245
245
  | `commands.set` | `object` | - | Set an objective against the revision the person saw. |
246
246
  | `commands.set.scope` | `"session"` | `"session"` | The Session owns this goal. |
247
- | `commands.set.input` | `Struct`\<\{ `objective`: `String`; `expectedRevision`: `NullOr`\<`String`\>; `requestId`: `String`; \}\> | - | Decode the new objective and replacement fence. |
247
+ | `commands.set.input` | `Struct`\<\{ `objective`: `String`; `expectedRevision`: `NullOr`\<`String`\>; `requestId`: `String`; `adoptCommandId`: `optionalKey`\<`String`\>; \}\> | - | Decode the new objective and replacement fence. |
248
248
  | `commands.set.output` | `Struct`\<\{ `goal`: `Struct`\<\{ `revision`: `String`; `objective`: `String`; `state`: `Literals`\<readonly \[`"active"`, `"paused"`\]\>; `startedAt`: `Int`; `pauseCause`: `NullOr`\<`Struct`\<\{ `kind`: `Literals`\<...\>; `detail`: `NullOr`\<...\>; \}\>\>; \}\>; `accepted`: `Struct`\<\{ `status`: `Literal`\<`"accepted"`\>; `commandId`: `String`; \}\>; \}\> | - | Return the goal and its admission receipt. |
249
249
  | <a id="property-projection"></a> `projection` | `object` | - | Current and ended goals visible to clients. |
250
250
  | `projection.schema` | `Struct`\<\{ `goal`: `NullOr`\<`Struct`\<\{ `revision`: `String`; `objective`: `String`; `state`: `Literals`\<readonly \[`"active"`, `"paused"`\]\>; `startedAt`: `Int`; `pauseCause`: `NullOr`\<`Struct`\<\{ `kind`: `Literals`\<...\>; `detail`: `NullOr`\<...\>; \}\>\>; \}\>\>; `ended`: `$Array`\<`Struct`\<\{ `sequence`: `Int`; `objective`: `String`; `outcome`: `Literals`\<readonly \[`"achieved"`, `"cleared"`\]\>; `reason`: `NullOr`\<`String`\>; `endedAt`: `Int`; `afterSequence`: `Int`; \}\>\>; `omitted`: `Int`; \}\> | `GoalProjection` | Decoder for the goal projection. |
@@ -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`