@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/README.md
CHANGED
|
@@ -65,16 +65,16 @@ helper borrows the Agent. Its owner closes it when the host's lifetime ends.
|
|
|
65
65
|
|
|
66
66
|
## Core concepts
|
|
67
67
|
|
|
68
|
-
| Extension | Ownership and behavior
|
|
69
|
-
| --------- |
|
|
70
|
-
| Ask | Up to four open decisions per Session. The model asks or withdraws; humans answer or decline. A live waiter receives the answer, while a parked question resumes through one follow-up.
|
|
71
|
-
| Goal | One live or paused objective per Session. The model creates, reads and completes it through `goal.*` tools, from any later turn of its Session; humans set, pause, resume or clear it. Esc, failed work, a block or three tool-less continuations pause it; a tool-less goal turn while a sub-agent command is live waits instead. Closing the app pauses it only if work stops without resuming; background work and runtime-auto-resumed turns keep it live. An achieved or cleared goal becomes one chat line in its history.
|
|
72
|
-
| Loop | One repeated prompt per Session. It runs on a fixed cron cadence or lets its own active run request the next occurrence. Commands start, pause, resume and stop it.
|
|
73
|
-
| Plan | One checklist per Session. `plan.update_plan` replaces it whole, with at most one step in progress and at most 50 steps; `steps: []` clears it. The human clears it with `/plan clear` or the footer row's Clear plan action, through the same owner. `/plan` opens the full checklist in the footer; updates keep only the current step and progress visible, and a finished or cleared plan leaves the footer while its history stays in state.
|
|
74
|
-
| Almanac | Agent-scope one-shot or recurring schedules. Each schedule targets a Session and tracks admission, retry and its next due time.
|
|
75
|
-
| Skills | Host-supplied, read-only skill discovery. Metadata enters the prompt; `skill.load` reads a skill body or supporting file; `/skills` lists them.
|
|
76
|
-
| Home | Agent-scope choice of an attached Computer. Each run captures its scoped file tools and current attachment choice.
|
|
77
|
-
| Subagents | A
|
|
68
|
+
| Extension | Ownership and behavior |
|
|
69
|
+
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
70
|
+
| Ask | Up to four open decisions per Session. The model asks or withdraws; humans answer or decline. A live waiter receives the answer, while a parked question resumes through one follow-up. On Local, an Ask raised during work another Agent sent waits on that sender first: it always parks, reads `waiting on <name>` instead of needing a person, and rides the child's reply to the sender as `waiting_on_you`. Only that sender's Session can answer it with `agents.answer`, or pass it to a person with `agents.escalate`; a person may always answer. An Ask the sender neither answers nor escalates goes to a person when the sender's turn ends, or after fifteen minutes. Hub sends every Ask to a person. |
|
|
71
|
+
| Goal | One live or paused objective per Session. The model creates, reads and completes it through `goal.*` tools, from any later turn of its Session; humans set, pause, resume or clear it. Esc, failed work, a block or three tool-less continuations pause it; a tool-less goal turn while a sub-agent command is live waits instead. Closing the app pauses it only if work stops without resuming; background work and runtime-auto-resumed turns keep it live. An achieved or cleared goal becomes one chat line in its history. |
|
|
72
|
+
| Loop | One repeated prompt per Session. It runs on a fixed cron cadence or lets its own active run request the next occurrence. Commands start, pause, resume and stop it. |
|
|
73
|
+
| Plan | One checklist per Session. `plan.update_plan` replaces it whole, with at most one step in progress and at most 50 steps; `steps: []` clears it. The human clears it with `/plan clear` or the footer row's Clear plan action, through the same owner. `/plan` opens the full checklist in the footer; updates keep only the current step and progress visible, and a finished or cleared plan leaves the footer while its history stays in state. |
|
|
74
|
+
| Almanac | Agent-scope one-shot or recurring schedules. Each schedule targets a Session and tracks admission, retry and its next due time. |
|
|
75
|
+
| Skills | Host-supplied, read-only skill discovery. Metadata enters the prompt; `skill.load` reads a skill body or supporting file; `/skills` lists them. |
|
|
76
|
+
| Home | Agent-scope choice of an attached Computer. Each run captures its scoped file tools and current attachment choice. |
|
|
77
|
+
| Subagents | A footer view: one row per Agent this Session sent work to or created, with its live status (working, waiting on an Agent, needs you, idle, failed, unavailable). A row opens that Agent's conversation, a question that needs a person can be answered from its row, and `/agents` collapses the rows to one summary row and expands them again. It owns no relationship and no durable state. |
|
|
78
78
|
|
|
79
79
|
An active Goal is quiet system context on every run. Continuation instructions
|
|
80
80
|
apply only to runs admitted by Goal with its continuation correlation; human
|
|
@@ -269,9 +269,14 @@ expands the footer rows, per Session and in memory).
|
|
|
269
269
|
`source.read(sessionId, limit)` returns the Agents that Session sent work to or
|
|
270
270
|
created, newest first, with a count of the rest; `source.open` is the host's
|
|
271
271
|
existing Agent access. Each child's status is its owner's own Session state:
|
|
272
|
-
`running` is working,
|
|
273
|
-
|
|
274
|
-
|
|
272
|
+
`running` is working, `done` is idle and `errored` is failed. Attention that
|
|
273
|
+
waits on a person (a question, or an approval an extension raises) is
|
|
274
|
+
`needs you`; a question that waits on the Agent that sent the work is
|
|
275
|
+
`waiting on <name>`, calm until it is escalated, and counted apart in the
|
|
276
|
+
collapsed summary, which is amber only when a child needs a person. A
|
|
277
|
+
`needs you` question carries its text and one Answer action that runs the
|
|
278
|
+
question's own answer conversation. A child whose state cannot be read is
|
|
279
|
+
unavailable, never idle.
|
|
275
280
|
|
|
276
281
|
Rows refresh when a watcher reads the parent Session and they are at least a
|
|
277
282
|
second old. While a row is working or waiting, they poll every five seconds
|
package/dist/goal/contract.d.ts
CHANGED
|
@@ -224,6 +224,12 @@ export declare const goalContract: {
|
|
|
224
224
|
readonly expectedRevision: Schema.NullOr<Schema.String>;
|
|
225
225
|
/** Idempotency key for this setting command. */
|
|
226
226
|
readonly requestId: Schema.String;
|
|
227
|
+
/**
|
|
228
|
+
* Work already admitted in this Session that carries the objective,
|
|
229
|
+
* such as a delegated send. The goal adopts it instead of admitting
|
|
230
|
+
* the objective again; its settlement starts the goal turns.
|
|
231
|
+
*/
|
|
232
|
+
readonly adoptCommandId: Schema.optionalKey<Schema.String>;
|
|
227
233
|
}>;
|
|
228
234
|
/** Return the goal and its admission receipt. */
|
|
229
235
|
readonly output: Schema.Struct<{
|
package/dist/goal/contract.js
CHANGED
|
@@ -154,6 +154,12 @@ export const goalContract = {
|
|
|
154
154
|
expectedRevision: Schema.NullOr(Schema.String),
|
|
155
155
|
/** Idempotency key for this setting command. */
|
|
156
156
|
requestId: RequestId,
|
|
157
|
+
/**
|
|
158
|
+
* Work already admitted in this Session that carries the objective,
|
|
159
|
+
* such as a delegated send. The goal adopts it instead of admitting
|
|
160
|
+
* the objective again; its settlement starts the goal turns.
|
|
161
|
+
*/
|
|
162
|
+
adoptCommandId: Schema.optionalKey(RequestId),
|
|
157
163
|
}),
|
|
158
164
|
/** Return the goal and its admission receipt. */
|
|
159
165
|
output: Schema.Struct({
|
package/dist/goal/host.js
CHANGED
|
@@ -14,14 +14,18 @@ import { clearConfirmation, goalMenuPrompt, replaceConfirmation, } from "./promp
|
|
|
14
14
|
const rules = "You may start a goal yourself when none is current. Before marking complete audit every requirement against evidence; complete only after actual achievement. Block only after the same external impasse for three consecutive goal turns; a blocked goal stays paused until it is resumed. You may complete, block, clear or resume a goal that paused on its own (stalled, blocked or failed work); a goal a person paused or stopped you may only complete, and only a person clears a live goal. Human pause/resume/clear remain human controls. Use plan.update_plan for multi-step work when available.";
|
|
15
15
|
/** Hashed prose and policy: every constant goal reads comes from here. */
|
|
16
16
|
const config = {
|
|
17
|
-
continuation: "Continue working toward the current goal described in the goal section. Find goal.get_goal and goal.update_goal with forage.search({query:'goal'}) and forage.describe. Audit every requirement against evidence before goal.update_goal({status:'complete', reason}). If the same external blocker persists for three goal turns, report blocked with a reason. Check for progress rather than repeating yourself.",
|
|
18
|
-
moduleDescription: "Session goal tools: create_goal({objective}), get_goal({}), update_goal({status:'complete'|'blocked'|'clear'|'resume', reason}). " +
|
|
17
|
+
continuation: "Continue working toward the current goal described in the goal section. Find goal.get_goal and goal.update_goal with forage.search({query:'goal'}) and forage.describe. Audit every requirement against evidence before goal.update_goal({status:'complete', reason}). If the same external blocker persists for three goal turns, report blocked with a reason. Check for progress rather than repeating yourself. If there is nothing to do until something outside this turn happens (a child's reply, a person, the train, main), call goal.wait({reason, fallbackMinutes}) and end the turn instead of polling.",
|
|
18
|
+
moduleDescription: "Session goal tools: create_goal({objective}), get_goal({}), update_goal({status:'complete'|'blocked'|'clear'|'resume', reason}), wait({reason, fallbackMinutes}). " +
|
|
19
19
|
rules,
|
|
20
|
-
createDescription: "Create a Session goal, including autonomously. Fails while a goal is active or paused; a finished goal is history.",
|
|
20
|
+
createDescription: "Create a Session goal, including autonomously. Fails while a goal is active or paused; a finished goal is history. After update_goal complete (or clear on a paused goal) in this same run, it queues the replacement, which takes over when this run settles.",
|
|
21
21
|
getDescription: "Read the current goal. A requested terminal state remains provisional until successful settlement.",
|
|
22
22
|
updateDescription: "Request complete only after actual achievement, or blocked only after the same external impasse for three consecutive goal turns; both also work on a paused goal, and settlement commits the request. Complete ends the goal; blocked pauses it for a person. clear abandons a paused goal with your reason. resume reactivates a paused goal now. A goal a person paused or stopped accepts only complete.",
|
|
23
23
|
waitCheckMs: 60_000,
|
|
24
|
+
waitDescription: "Say the goal is waiting on something outside this turn, then end the turn. The goal stays active and admits no goal turn and no stall count until another run in this Session settles (a child's reply, a person's message, a loop tick) or fallbackMinutes (default 20, 1 to 60) pass. Use it instead of polling.",
|
|
25
|
+
waitDefaultMinutes: 20,
|
|
26
|
+
waitMaxMinutes: 60,
|
|
24
27
|
waitingSummary: "Waiting on sub-agent",
|
|
28
|
+
waitingOnSummary: "Waiting",
|
|
25
29
|
promptLimit: 1_024,
|
|
26
30
|
stallTurns: 3,
|
|
27
31
|
stallReason: "Stalled: no progress for 3 turns",
|
|
@@ -61,6 +65,17 @@ const GoalRow = Schema.Struct({
|
|
|
61
65
|
reason: GoalReason,
|
|
62
66
|
})),
|
|
63
67
|
stalledTurns: Schema.Int,
|
|
68
|
+
/**
|
|
69
|
+
* The goal waits on something outside its turns. `until` is null while the
|
|
70
|
+
* requesting run is still running; its settlement fixes the fallback instant.
|
|
71
|
+
*/
|
|
72
|
+
waiting: Schema.optionalKey(Schema.NullOr(Schema.Struct({
|
|
73
|
+
reason: GoalReason,
|
|
74
|
+
fallbackMs: Schema.Int,
|
|
75
|
+
until: Schema.NullOr(Schema.Int),
|
|
76
|
+
}))),
|
|
77
|
+
/** A replacement objective queued after this run's complete or clear. */
|
|
78
|
+
successor: Schema.optionalKey(Schema.NullOr(Objective)),
|
|
64
79
|
/** Request that set this revision; its retry replays. */
|
|
65
80
|
revisionRequestId: Schema.String,
|
|
66
81
|
/** Latest work whose settlement advances this goal. */
|
|
@@ -127,6 +142,8 @@ function paused(goal, kind, detail) {
|
|
|
127
142
|
state: "paused",
|
|
128
143
|
pauseCause: { kind, detail },
|
|
129
144
|
pending: null,
|
|
145
|
+
waiting: null,
|
|
146
|
+
successor: null,
|
|
130
147
|
};
|
|
131
148
|
}
|
|
132
149
|
/**
|
|
@@ -167,6 +184,7 @@ function changeActivity(ctx, sessionId, target) {
|
|
|
167
184
|
state: "active",
|
|
168
185
|
pauseCause: null,
|
|
169
186
|
stalledTurns: 0,
|
|
187
|
+
waiting: null,
|
|
170
188
|
}
|
|
171
189
|
: paused(goal, "person", null);
|
|
172
190
|
yield* commitGoal(ctx, changed);
|
|
@@ -225,26 +243,77 @@ function settle(ctx, run, subagents) {
|
|
|
225
243
|
yield* commitGoal(ctx, paused(goal, "no-terminal", ctx.config.noTerminalReason));
|
|
226
244
|
return yield* ctx.observe(run.sessionId);
|
|
227
245
|
}
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
246
|
+
const goalTurn = run.origin?.extensionId === goalContract.id;
|
|
247
|
+
// A live child command or a question or approval a person has yet to
|
|
248
|
+
// answer is a legitimate wait, whether or not this turn polled.
|
|
249
|
+
const waitingForChild = goalTurn &&
|
|
250
|
+
(subagents?.hasLiveChildCommand(run.sessionId) === true ||
|
|
251
|
+
(yield* ctx.attention.awaitsPerson(run.sessionId)));
|
|
252
|
+
const waiting = goal.waiting ?? null;
|
|
253
|
+
// The run that called goal.wait fixes the fallback instant as it settles.
|
|
254
|
+
const requested = waiting !== null &&
|
|
255
|
+
waiting.until === null &&
|
|
256
|
+
run.workId === goal.trackedWorkId;
|
|
257
|
+
// Any other run that settles while a wait is armed wakes the goal.
|
|
258
|
+
const woken = waiting !== null && waiting.until !== null;
|
|
231
259
|
const tracked = {
|
|
232
260
|
...goal,
|
|
233
261
|
trackedWorkId: run.workId,
|
|
234
|
-
stalledTurns:
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
262
|
+
stalledTurns: requested
|
|
263
|
+
? goal.stalledTurns
|
|
264
|
+
: run.origin?.extensionId === goalContract.id &&
|
|
265
|
+
run.origin.correlation === goal.revision
|
|
266
|
+
? run.toolCalls === 0
|
|
267
|
+
? goal.stalledTurns + 1
|
|
268
|
+
: 0
|
|
269
|
+
: 0,
|
|
240
270
|
};
|
|
241
271
|
const pending = goal.trackedWorkId === run.workId ? goal.pending : null;
|
|
242
|
-
if (pending?.state === "complete")
|
|
243
|
-
yield*
|
|
244
|
-
|
|
245
|
-
|
|
272
|
+
if (pending?.state === "complete" || pending?.state === "cleared") {
|
|
273
|
+
yield* ctx.timers.cancel(waitTimerKey(run.sessionId));
|
|
274
|
+
yield* finishGoal(ctx, tracked, pending.state === "complete" ? "achieved" : "cleared", pending.reason);
|
|
275
|
+
if (goal.successor != null) {
|
|
276
|
+
const next = {
|
|
277
|
+
sessionId: run.sessionId,
|
|
278
|
+
objective: goal.successor,
|
|
279
|
+
state: "active",
|
|
280
|
+
startedAt: yield* Clock.currentTimeMillis,
|
|
281
|
+
pending: null,
|
|
282
|
+
stalledTurns: 0,
|
|
283
|
+
revision: `run:${run.workId}`,
|
|
284
|
+
revisionRequestId: `run:${run.workId}`,
|
|
285
|
+
trackedWorkId: run.workId,
|
|
286
|
+
pauseCause: null,
|
|
287
|
+
waiting: null,
|
|
288
|
+
successor: null,
|
|
289
|
+
};
|
|
290
|
+
yield* commitGoal(ctx, yield* continueIfIdle(ctx, next, `goal-continue:${run.workId}`));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
246
293
|
else if (pending?.state === "blocked")
|
|
247
294
|
yield* commitGoal(ctx, paused(tracked, "blocked", pending.reason));
|
|
295
|
+
else if (goal.state === "active" && requested) {
|
|
296
|
+
// goal.wait: no continuation and no stall; the next settled run or the
|
|
297
|
+
// fallback timer wakes the goal.
|
|
298
|
+
const now = yield* Clock.currentTimeMillis;
|
|
299
|
+
const until = now + waiting.fallbackMs;
|
|
300
|
+
yield* commitGoal(ctx, {
|
|
301
|
+
...tracked,
|
|
302
|
+
waiting: { ...waiting, until },
|
|
303
|
+
});
|
|
304
|
+
yield* ctx.attention.set(run.sessionId, {
|
|
305
|
+
id: "waiting-goal",
|
|
306
|
+
kind: "goal-waiting",
|
|
307
|
+
inbox: false,
|
|
308
|
+
summary: `${ctx.config.waitingOnSummary}: ${waiting.reason}`.slice(0, 200),
|
|
309
|
+
since: now,
|
|
310
|
+
});
|
|
311
|
+
yield* ctx.timers.set(waitTimerKey(run.sessionId), until);
|
|
312
|
+
}
|
|
313
|
+
else if (goal.state === "active" && woken) {
|
|
314
|
+
yield* ctx.timers.cancel(waitTimerKey(run.sessionId));
|
|
315
|
+
yield* commitGoal(ctx, yield* continueIfIdle(ctx, { ...tracked, waiting: null }, `goal-continue:${run.workId}`));
|
|
316
|
+
}
|
|
248
317
|
else if (goal.state === "active" &&
|
|
249
318
|
!waitingForChild &&
|
|
250
319
|
tracked.stalledTurns >= ctx.config.stallTurns)
|
|
@@ -288,7 +357,15 @@ function recheckWaiting(ctx, timer, subagents) {
|
|
|
288
357
|
const found = yield* ctx.state.goals.get(sessionId);
|
|
289
358
|
if (Option.isNone(found) || found.value.state !== "active")
|
|
290
359
|
return;
|
|
291
|
-
|
|
360
|
+
const armed = found.value.waiting ?? null;
|
|
361
|
+
if (armed !== null) {
|
|
362
|
+
// goal.wait's fallback: a re-armed wait ignores a stale firing.
|
|
363
|
+
if (armed.until === null || armed.until > timer.firedAt)
|
|
364
|
+
return;
|
|
365
|
+
return yield* commitGoal(ctx, yield* continueIfIdle(ctx, { ...found.value, waiting: null, stalledTurns: 0 }, `goal-wait:${found.value.trackedWorkId}`)).pipe(Effect.andThen(ctx.observe(sessionId)));
|
|
366
|
+
}
|
|
367
|
+
if (subagents?.hasLiveChildCommand(sessionId) === true ||
|
|
368
|
+
(yield* ctx.attention.awaitsPerson(sessionId)))
|
|
292
369
|
return yield* ctx.timers.set(timer.key, timer.firedAt + ctx.config.waitCheckMs);
|
|
293
370
|
yield* commitGoal(ctx, yield* continueIfIdle(ctx, found.value, `goal-wait:${found.value.trackedWorkId}`));
|
|
294
371
|
yield* ctx.observe(sessionId);
|
|
@@ -349,8 +426,21 @@ function goalModule(ctx, sessionId, workId, initialRevision) {
|
|
|
349
426
|
}),
|
|
350
427
|
execute: ({ objective }) => toolCall(ctx, "create_goal", Effect.gen(function* () {
|
|
351
428
|
const decoded = yield* Schema.decodeUnknownEffect(Objective)(objective).pipe(Effect.mapError(() => conflict("Goal objective is invalid")));
|
|
352
|
-
|
|
353
|
-
|
|
429
|
+
const existing = yield* ctx.state.goals.get(sessionId);
|
|
430
|
+
if (Option.isSome(existing)) {
|
|
431
|
+
// A goal this run already finished (complete, or clear of a
|
|
432
|
+
// paused one) hands over to the replacement at settlement.
|
|
433
|
+
const retiring = existing.value.trackedWorkId === workId &&
|
|
434
|
+
(existing.value.pending?.state === "complete" ||
|
|
435
|
+
existing.value.pending?.state === "cleared");
|
|
436
|
+
if (!retiring)
|
|
437
|
+
return yield* Effect.fail(conflict("A Session goal is already active or paused; complete it first, or only a person replaces it"));
|
|
438
|
+
yield* ctx.state.goals.put({
|
|
439
|
+
...existing.value,
|
|
440
|
+
successor: decoded,
|
|
441
|
+
});
|
|
442
|
+
return { objective: decoded, state: "active" };
|
|
443
|
+
}
|
|
354
444
|
yield* commitGoal(ctx, {
|
|
355
445
|
sessionId,
|
|
356
446
|
objective: decoded,
|
|
@@ -362,6 +452,8 @@ function goalModule(ctx, sessionId, workId, initialRevision) {
|
|
|
362
452
|
revisionRequestId: `run:${workId}`,
|
|
363
453
|
trackedWorkId: workId,
|
|
364
454
|
pauseCause: null,
|
|
455
|
+
waiting: null,
|
|
456
|
+
successor: null,
|
|
365
457
|
});
|
|
366
458
|
yield* ctx.observe(sessionId);
|
|
367
459
|
return { objective: decoded, state: "active" };
|
|
@@ -389,6 +481,50 @@ function goalModule(ctx, sessionId, workId, initialRevision) {
|
|
|
389
481
|
null,
|
|
390
482
|
}))),
|
|
391
483
|
}),
|
|
484
|
+
wait: Mycelium.operation({
|
|
485
|
+
replay: "unsafe",
|
|
486
|
+
description: ctx.config.waitDescription,
|
|
487
|
+
input: z.strictObject({
|
|
488
|
+
reason: z.string().min(1).max(1_000),
|
|
489
|
+
fallbackMinutes: z
|
|
490
|
+
.number()
|
|
491
|
+
.int()
|
|
492
|
+
.min(1)
|
|
493
|
+
.max(ctx.config.waitMaxMinutes)
|
|
494
|
+
.optional(),
|
|
495
|
+
}),
|
|
496
|
+
output: z.strictObject({
|
|
497
|
+
objective: toolObjective,
|
|
498
|
+
state: z.literal("active"),
|
|
499
|
+
reason: z.string(),
|
|
500
|
+
fallbackMinutes: z.number().int(),
|
|
501
|
+
}),
|
|
502
|
+
execute: ({ reason, fallbackMinutes }) => toolCall(ctx, "wait", Effect.gen(function* () {
|
|
503
|
+
const decoded = yield* Schema.decodeUnknownEffect(GoalReason)(reason).pipe(Effect.mapError(() => conflict("Wait reason is invalid")));
|
|
504
|
+
const goal = yield* currentGoal;
|
|
505
|
+
if (goal.state !== "active")
|
|
506
|
+
return yield* Effect.fail(conflict("Only an active Session goal can wait"));
|
|
507
|
+
if (goal.pending != null)
|
|
508
|
+
return yield* Effect.fail(conflict("This run already requested a goal terminal"));
|
|
509
|
+
const minutes = fallbackMinutes ?? ctx.config.waitDefaultMinutes;
|
|
510
|
+
yield* ctx.state.goals.put({
|
|
511
|
+
...goal,
|
|
512
|
+
// This run's settlement starts the wait.
|
|
513
|
+
trackedWorkId: workId,
|
|
514
|
+
waiting: {
|
|
515
|
+
reason: decoded,
|
|
516
|
+
fallbackMs: minutes * 60_000,
|
|
517
|
+
until: null,
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
return {
|
|
521
|
+
objective: goal.objective,
|
|
522
|
+
state: "active",
|
|
523
|
+
reason: decoded,
|
|
524
|
+
fallbackMinutes: minutes,
|
|
525
|
+
};
|
|
526
|
+
})),
|
|
527
|
+
}),
|
|
392
528
|
update_goal: Mycelium.operation({
|
|
393
529
|
replay: "unsafe",
|
|
394
530
|
description: ctx.config.updateDescription,
|
|
@@ -474,15 +610,22 @@ function setGoal(ctx, sessionId, input) {
|
|
|
474
610
|
const retried = current?.revisionRequestId === input.requestId;
|
|
475
611
|
if (!retried && (current?.revision ?? null) !== input.expectedRevision)
|
|
476
612
|
return yield* Effect.fail(conflict("Session goal changed; read it before replacing it"));
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
613
|
+
// Adopted work already carries the objective; anything else admits it.
|
|
614
|
+
const admitted = input.adoptCommandId === undefined
|
|
615
|
+
? yield* ctx.send({
|
|
616
|
+
sessionId,
|
|
617
|
+
when: "now",
|
|
618
|
+
prompt: {
|
|
619
|
+
text: input.objective,
|
|
620
|
+
requestId: input.requestId,
|
|
621
|
+
delivery: "followUp",
|
|
622
|
+
},
|
|
623
|
+
})
|
|
624
|
+
: {
|
|
625
|
+
_tag: "Accepted",
|
|
626
|
+
commandId: input.adoptCommandId,
|
|
627
|
+
delivery: retried ? "replay" : "new",
|
|
628
|
+
};
|
|
486
629
|
if (admitted._tag !== "Accepted")
|
|
487
630
|
return yield* Effect.fail(conflict("Session goal was not admitted"));
|
|
488
631
|
const accepted = {
|
|
@@ -511,10 +654,17 @@ function setGoal(ctx, sessionId, input) {
|
|
|
511
654
|
revisionRequestId: input.requestId,
|
|
512
655
|
trackedWorkId: admitted.commandId,
|
|
513
656
|
pauseCause: null,
|
|
657
|
+
waiting: null,
|
|
658
|
+
successor: null,
|
|
514
659
|
};
|
|
515
|
-
|
|
660
|
+
// Adopted work that already settled left the Session idle: start the
|
|
661
|
+
// first goal turn now. Otherwise its settlement does.
|
|
662
|
+
const started = input.adoptCommandId === undefined
|
|
663
|
+
? row
|
|
664
|
+
: yield* continueIfIdle(ctx, row, `goal-adopt:${input.requestId}`);
|
|
665
|
+
yield* commitGoal(ctx, started);
|
|
516
666
|
yield* ctx.observe(sessionId);
|
|
517
|
-
return { goal: committed(
|
|
667
|
+
return { goal: committed(started), accepted };
|
|
518
668
|
});
|
|
519
669
|
}
|
|
520
670
|
/** The one-line state the model prompt and attention summaries carry. */
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { WhistleCatalogStepV1 } from "@fungi.computer/whistle/catalog";
|
|
2
|
+
import { ExtensionCommandError } from "@fungi.computer/shiitake/extensions";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import { type Answers, type AnswerFlowInput, type Question } from "./contract.js";
|
|
5
|
+
import type * as Schema from "effect/Schema";
|
|
6
|
+
/** Drafts travel in catalog arguments; only an owner's answer commits them. */
|
|
7
|
+
export type FlowInput = Schema.Schema.Type<typeof AnswerFlowInput>;
|
|
8
|
+
/** What the answer conversation does next for one open Ask. */
|
|
9
|
+
export type AnswerFlowOutcome = Readonly<{
|
|
10
|
+
type: "step";
|
|
11
|
+
step: WhistleCatalogStepV1;
|
|
12
|
+
}> | Readonly<{
|
|
13
|
+
type: "complete";
|
|
14
|
+
askId: string;
|
|
15
|
+
requestId: string;
|
|
16
|
+
answers: Answers;
|
|
17
|
+
}>;
|
|
18
|
+
/**
|
|
19
|
+
* Progress one Ask's answer draft by a single human step. The owner of the
|
|
20
|
+
* Ask commits a completed set; a step only ever returns the next prompt, so
|
|
21
|
+
* the same conversation serves the Ask's own Session and a parent's footer.
|
|
22
|
+
*/
|
|
23
|
+
export declare function advanceAnswerFlow(row: Readonly<{
|
|
24
|
+
askId: string;
|
|
25
|
+
questions: readonly Question[];
|
|
26
|
+
closed: boolean;
|
|
27
|
+
}>, input: FlowInput): Effect.Effect<AnswerFlowOutcome, ExtensionCommandError>;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { ExtensionCommandError } from "@fungi.computer/shiitake/extensions";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import { ask, done, freshRequestId } from "../internal/catalog.js";
|
|
4
|
+
import { titleLimit } from "../internal/headline.js";
|
|
5
|
+
import {} from "./contract.js";
|
|
6
|
+
/** Accept only picks the question offered, and a non-empty answer. */
|
|
7
|
+
function decisionAnswer(question, answer) {
|
|
8
|
+
const offered = new Set(question.options.map((option) => option.label));
|
|
9
|
+
const written = answer.text?.trim() ?? "";
|
|
10
|
+
if (answer.selected.some((label) => !offered.has(label)) ||
|
|
11
|
+
new Set(answer.selected).size !== answer.selected.length ||
|
|
12
|
+
(question.multiple !== true && answer.selected.length > 1) ||
|
|
13
|
+
(answer.selected.length === 0 && written === ""))
|
|
14
|
+
return Effect.fail(new ExtensionCommandError({
|
|
15
|
+
reason: "invalid",
|
|
16
|
+
message: "Choose offered options or write an answer",
|
|
17
|
+
}));
|
|
18
|
+
return Effect.succeed(written === ""
|
|
19
|
+
? { selected: answer.selected }
|
|
20
|
+
: { selected: answer.selected, text: answer.text ?? "" });
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The Agent's own words are the title only when they fit one; a longer
|
|
24
|
+
* question gets a plain title and sits in the body, whole in the details.
|
|
25
|
+
*/
|
|
26
|
+
function questionHeading(question) {
|
|
27
|
+
const text = question.question.replace(/\s+/gu, " ").trim();
|
|
28
|
+
if (text.length <= titleLimit)
|
|
29
|
+
return { message: text };
|
|
30
|
+
return {
|
|
31
|
+
message: "The Agent has a question",
|
|
32
|
+
body: question.question.slice(0, 4_000),
|
|
33
|
+
bodyLabel: "Question",
|
|
34
|
+
details: [{ label: "Question", value: question.question.slice(0, 4_000) }],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function promptQuestion(nextQuestion, next, total) {
|
|
38
|
+
const base = {
|
|
39
|
+
type: "decision",
|
|
40
|
+
...questionHeading(nextQuestion),
|
|
41
|
+
options: nextQuestion.options.map((option) => ({
|
|
42
|
+
value: option.label,
|
|
43
|
+
label: option.label,
|
|
44
|
+
description: option.description,
|
|
45
|
+
})),
|
|
46
|
+
other: {
|
|
47
|
+
label: "Other…",
|
|
48
|
+
placeholder: "Write your own answer",
|
|
49
|
+
maxLength: 4_000,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
// Only present fields travel: the wire is JSON, which has no undefined.
|
|
53
|
+
const numbered = total > 1
|
|
54
|
+
? {
|
|
55
|
+
...base,
|
|
56
|
+
summary: `Question ${String(next.index + 1)} of ${String(total)}`,
|
|
57
|
+
}
|
|
58
|
+
: base;
|
|
59
|
+
return ask(nextQuestion.multiple === true ? { ...numbered, multiple: true } : numbered, { stage: "decision", ...next }, "answer");
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Progress one Ask's answer draft by a single human step. The owner of the
|
|
63
|
+
* Ask commits a completed set; a step only ever returns the next prompt, so
|
|
64
|
+
* the same conversation serves the Ask's own Session and a parent's footer.
|
|
65
|
+
*/
|
|
66
|
+
export function advanceAnswerFlow(row, input) {
|
|
67
|
+
return Effect.gen(function* () {
|
|
68
|
+
const draft = "stage" in input
|
|
69
|
+
? input
|
|
70
|
+
: {
|
|
71
|
+
stage: "start",
|
|
72
|
+
index: 0,
|
|
73
|
+
requestId: yield* freshRequestId,
|
|
74
|
+
answers: {},
|
|
75
|
+
};
|
|
76
|
+
let { index, answers } = draft;
|
|
77
|
+
const { requestId } = draft;
|
|
78
|
+
const question = row.questions[index];
|
|
79
|
+
if (question === undefined)
|
|
80
|
+
return yield* Effect.fail(new ExtensionCommandError({
|
|
81
|
+
reason: "invalid",
|
|
82
|
+
message: "Unknown question step",
|
|
83
|
+
}));
|
|
84
|
+
const answer = draft.stage === "decision"
|
|
85
|
+
? yield* decisionAnswer(question, draft.answer)
|
|
86
|
+
: undefined;
|
|
87
|
+
if (answer !== undefined) {
|
|
88
|
+
answers = { ...answers, [question.id]: answer };
|
|
89
|
+
index += 1;
|
|
90
|
+
if (index === row.questions.length)
|
|
91
|
+
return {
|
|
92
|
+
type: "complete",
|
|
93
|
+
askId: row.askId,
|
|
94
|
+
requestId,
|
|
95
|
+
answers,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (row.closed)
|
|
99
|
+
return {
|
|
100
|
+
type: "step",
|
|
101
|
+
step: done({
|
|
102
|
+
type: "info",
|
|
103
|
+
message: "That question is already closed.",
|
|
104
|
+
}),
|
|
105
|
+
};
|
|
106
|
+
const next = { askId: row.askId, requestId, index, answers };
|
|
107
|
+
const nextQuestion = row.questions[index];
|
|
108
|
+
if (nextQuestion === undefined)
|
|
109
|
+
throw new Error("Expected the next question");
|
|
110
|
+
return {
|
|
111
|
+
type: "step",
|
|
112
|
+
step: promptQuestion(nextQuestion, next, row.questions.length),
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ClientContributionV1 } from "@fungi.computer/shiitake/client-entry";
|
|
2
|
-
/** Pending decisions
|
|
2
|
+
/** Pending decisions take over the composer; the footer row is where a postponed one waits. */
|
|
3
3
|
declare const _default: Readonly<{
|
|
4
4
|
protocol: "shiitake.client/v1";
|
|
5
5
|
id: import("@fungi.computer/shiitake/extensions").ExtensionId;
|
package/dist/question/client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineClientEntry, } from "@fungi.computer/shiitake/client-entry";
|
|
2
2
|
import { questionContract } from "./contract.js";
|
|
3
|
-
/** Pending decisions
|
|
3
|
+
/** Pending decisions take over the composer; the footer row is where a postponed one waits. */
|
|
4
4
|
export default defineClientEntry(questionContract, {
|
|
5
5
|
contribute: (view) => ({
|
|
6
6
|
chips: [],
|
|
@@ -8,16 +8,35 @@ export default defineClientEntry(questionContract, {
|
|
|
8
8
|
? []
|
|
9
9
|
: view.value.asks.map((ask) => {
|
|
10
10
|
const first = ask.questions[0];
|
|
11
|
+
const title = `${first?.question ?? "Question"}${ask.questions.length > 1 ? ` (+${String(ask.questions.length - 1)} more)` : ""}`;
|
|
12
|
+
const detail = first?.options.map((option) => option.label).join(" · ") ?? "";
|
|
13
|
+
// An ask waiting on an Agent is calm: it never takes over the
|
|
14
|
+
// composer, but a person may still answer it first.
|
|
15
|
+
if (ask.waitingOn !== undefined)
|
|
16
|
+
return {
|
|
17
|
+
id: ask.askId,
|
|
18
|
+
title: `waiting on ${ask.waitingOn.name} · ${title}`,
|
|
19
|
+
detail,
|
|
20
|
+
tone: "muted",
|
|
21
|
+
actions: [
|
|
22
|
+
{
|
|
23
|
+
id: "answerFlow",
|
|
24
|
+
label: "Answer",
|
|
25
|
+
input: { askId: ask.askId },
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
};
|
|
11
29
|
return {
|
|
12
30
|
id: ask.askId,
|
|
13
|
-
title
|
|
14
|
-
detail
|
|
31
|
+
title,
|
|
32
|
+
detail,
|
|
15
33
|
tone: "attention",
|
|
16
34
|
actions: [
|
|
17
35
|
{
|
|
18
36
|
id: "answerFlow",
|
|
19
37
|
label: "Answer",
|
|
20
38
|
input: { askId: ask.askId },
|
|
39
|
+
awaiting: "question",
|
|
21
40
|
},
|
|
22
41
|
],
|
|
23
42
|
};
|