Archived
feat(sessions): add the ask_user tool
Register a core ask_user custom tool that posts a question set to the user's browser and terminates the run instead of awaiting an answer. The tool is thin: it shapes its TypeBox params into domain questions, lets PendingAskStore own validation, and reports a superseded unanswered ask back to the model. Gated by the askUser config key, threaded through PiSessionServiceDependencies and sessiond. Unlike the delegation tools, ask_user is available to tracked children too: the questions reach the user of the asking session.
This commit is contained in:
@@ -77,6 +77,7 @@ await runSessionDaemonStartup({
|
|||||||
logger: app.log,
|
logger: app.log,
|
||||||
...(spawnTargets === undefined ? {} : { spawnTargets }),
|
...(spawnTargets === undefined ? {} : { spawnTargets }),
|
||||||
subsessionsEnabled: spawnTargets !== undefined && config.subsessions,
|
subsessionsEnabled: spawnTargets !== undefined && config.subsessions,
|
||||||
|
askUserEnabled: config.askUser,
|
||||||
notificationStore,
|
notificationStore,
|
||||||
unreadStore,
|
unreadStore,
|
||||||
// Read-only, so session startup can tell a waiting user that provider
|
// Read-only, so session startup can tell a waiting user that provider
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
||||||
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createAskUserToolDefinition, type AskUserInvocation } from "./askUserTool.js";
|
||||||
|
import { PendingAskStore, PendingAskValidationError } from "./pendingAskStore.js";
|
||||||
|
|
||||||
|
function ctxFor(sessionId: string): ExtensionContext {
|
||||||
|
const sessionManager = { getSessionId: () => sessionId, getSessionFile: () => undefined };
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tool uses.
|
||||||
|
return { sessionManager } as unknown as ExtensionContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstText(content: readonly (TextContent | ImageContent)[]): string {
|
||||||
|
const first = content[0];
|
||||||
|
return first?.type === "text" ? first.text : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tool over a real store, so the schema and the store's contract stay aligned. */
|
||||||
|
function toolOverStore(askIds: string[] = ["ask-1", "ask-2"]) {
|
||||||
|
const remaining = [...askIds];
|
||||||
|
const store = new PendingAskStore({
|
||||||
|
now: () => new Date("2026-02-01T10:00:00.000Z"),
|
||||||
|
createAskId: () => remaining.shift() ?? "ask-exhausted",
|
||||||
|
});
|
||||||
|
const open = vi.fn((input: AskUserInvocation) => Promise.resolve(store.open(input)));
|
||||||
|
return { store, open, tool: createAskUserToolDefinition({ open }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const twoQuestions = {
|
||||||
|
questions: [
|
||||||
|
{ id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }, { value: "sqlite", label: "SQLite" }] },
|
||||||
|
{ id: "why", question: "Why?", options: [], allowOther: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("createAskUserToolDefinition", () => {
|
||||||
|
it("advertises a non-blocking question set whose answers arrive as a follow-up", () => {
|
||||||
|
const { tool } = toolOverStore();
|
||||||
|
|
||||||
|
expect(tool.name).toBe("ask_user");
|
||||||
|
expect(tool.description).toBe("Post a set of questions to the user as a browser form and end this run. Answers arrive later as a follow-up message; the user may leave any question unanswered.");
|
||||||
|
expect(tool.promptSnippet).toBe("ask_user: post a question set to the user; ends the run, answers return as a follow-up");
|
||||||
|
expect(tool.promptGuidelines).toEqual([
|
||||||
|
"When you need decisions from the user, post them together with ask_user instead of asking in prose one at a time. It ends the run and the answers, including the questions the user left unanswered, come back as a follow-up message that wakes you. Call it alone and last, and do not repost the same questions or poll for answers.",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds the question set in its parameter schema", () => {
|
||||||
|
const { tool } = toolOverStore();
|
||||||
|
|
||||||
|
expect(tool.parameters).toMatchObject({
|
||||||
|
type: "object",
|
||||||
|
required: ["questions"],
|
||||||
|
properties: { questions: { type: "array", minItems: 1, maxItems: 20 } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the ask for the calling session and terminates the run instead of awaiting the user", async () => {
|
||||||
|
const { open, tool } = toolOverStore();
|
||||||
|
|
||||||
|
const result = await tool.execute("call-1", twoQuestions, undefined, undefined, ctxFor("session-1"));
|
||||||
|
|
||||||
|
expect(open).toHaveBeenCalledWith({
|
||||||
|
sessionId: "session-1",
|
||||||
|
questions: [
|
||||||
|
{ id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }, { value: "sqlite", label: "SQLite" }] },
|
||||||
|
{ id: "why", question: "Why?", options: [], allowOther: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result.terminate).toBe(true);
|
||||||
|
expect(result.details).toMatchObject({ ask: { askId: "ask-1", questions: [{ id: "db" }, { id: "why" }] } });
|
||||||
|
expect(firstText(result.content)).toBe("Posted 2 questions to the user as ask ask-1. Ending this run; the answers arrive as a follow-up message that wakes you, naming every question the user left unanswered. Do not repost these questions.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults a question without options to an empty option list so free text alone is expressible", async () => {
|
||||||
|
const { open, tool } = toolOverStore();
|
||||||
|
|
||||||
|
await tool.execute("call-free", { questions: [{ id: "note", question: "Anything else?", allowOther: true }] }, undefined, undefined, ctxFor("session-1"));
|
||||||
|
|
||||||
|
expect(open).toHaveBeenCalledWith({
|
||||||
|
sessionId: "session-1",
|
||||||
|
questions: [{ id: "note", question: "Anything else?", options: [], allowOther: true }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards per-question detail, option detail, and multi-select without inventing defaults", async () => {
|
||||||
|
const { open, tool } = toolOverStore();
|
||||||
|
|
||||||
|
await tool.execute("call-rich", {
|
||||||
|
questions: [{
|
||||||
|
id: "targets",
|
||||||
|
question: "Which targets?",
|
||||||
|
detail: "Pick every platform we should build for.",
|
||||||
|
options: [{ value: "web", label: "Web", detail: "Chromium and Firefox" }, { value: "cli", label: "CLI" }],
|
||||||
|
multiple: true,
|
||||||
|
}],
|
||||||
|
}, undefined, undefined, ctxFor("session-1"));
|
||||||
|
|
||||||
|
expect(open).toHaveBeenCalledWith({
|
||||||
|
sessionId: "session-1",
|
||||||
|
questions: [{
|
||||||
|
id: "targets",
|
||||||
|
question: "Which targets?",
|
||||||
|
detail: "Pick every platform we should build for.",
|
||||||
|
options: [{ value: "web", label: "Web", detail: "Chromium and Firefox" }, { value: "cli", label: "CLI" }],
|
||||||
|
multiple: true,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tells the model which questions the superseded ask left unanswered", async () => {
|
||||||
|
const { tool } = toolOverStore();
|
||||||
|
await tool.execute("call-first", twoQuestions, undefined, undefined, ctxFor("session-1"));
|
||||||
|
|
||||||
|
const result = await tool.execute("call-second", { questions: [{ id: "again", question: "Still there?", options: [{ value: "yes", label: "Yes" }] }] }, undefined, undefined, ctxFor("session-1"));
|
||||||
|
|
||||||
|
expect(result.terminate).toBe(true);
|
||||||
|
expect(firstText(result.content)).toContain("Posted 1 question to the user as ask ask-2.");
|
||||||
|
expect(firstText(result.content)).toContain("This replaced an earlier question set (ask-1) that the user never submitted.");
|
||||||
|
expect(firstText(result.content)).toContain("Left unanswered: db, why.");
|
||||||
|
expect(result.details).toMatchObject({ superseded: { askId: "ask-1", reason: "superseded", unansweredIds: ["db", "why"] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing about superseding when no earlier ask was open", async () => {
|
||||||
|
const { tool } = toolOverStore();
|
||||||
|
|
||||||
|
const result = await tool.execute("call-only", twoQuestions, undefined, undefined, ctxFor("session-1"));
|
||||||
|
|
||||||
|
expect(firstText(result.content)).not.toContain("replaced an earlier question set");
|
||||||
|
expect(result.details).not.toHaveProperty("superseded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a rejected question set so the agent loop reports it to the model", async () => {
|
||||||
|
const { tool } = toolOverStore();
|
||||||
|
|
||||||
|
await expect(tool.execute("call-dup", {
|
||||||
|
questions: [
|
||||||
|
{ id: "same", question: "First?", options: [{ value: "a", label: "A" }] },
|
||||||
|
{ id: "same", question: "Second?", options: [{ value: "b", label: "B" }] },
|
||||||
|
],
|
||||||
|
}, undefined, undefined, ctxFor("session-1"))).rejects.toThrow(PendingAskValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { Type, type Static } from "typebox";
|
||||||
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||||
|
import {
|
||||||
|
ASK_USER_ID_MAX_LENGTH,
|
||||||
|
ASK_USER_OPTION_LIMIT,
|
||||||
|
ASK_USER_QUESTION_LIMIT,
|
||||||
|
ASK_USER_TEXT_MAX_LENGTH,
|
||||||
|
type AskUserQuestion,
|
||||||
|
type AskUserQuestionOption,
|
||||||
|
} from "../../shared/apiTypes.js";
|
||||||
|
import { renderSupersededAskText, type PendingAskOpenResult } from "./pendingAskStore.js";
|
||||||
|
|
||||||
|
/** One `ask_user` call: the questions to post, for the session that called the tool. */
|
||||||
|
export interface AskUserInvocation {
|
||||||
|
sessionId: string;
|
||||||
|
questions: AskUserQuestion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AskUserToolDeps {
|
||||||
|
/** Registers the ask as the session's open one; rejects question sets the user could not answer. */
|
||||||
|
open(input: AskUserInvocation): Promise<PendingAskOpenResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AskUserToolDetails = PendingAskOpenResult;
|
||||||
|
|
||||||
|
const AskUserOptionParams = Type.Object({
|
||||||
|
value: Type.String({
|
||||||
|
maxLength: ASK_USER_ID_MAX_LENGTH,
|
||||||
|
description: "Stable machine value reported back to you when the user picks this option.",
|
||||||
|
}),
|
||||||
|
label: Type.String({
|
||||||
|
maxLength: ASK_USER_TEXT_MAX_LENGTH,
|
||||||
|
description: "Short label the user reads, ideally a few words.",
|
||||||
|
}),
|
||||||
|
detail: Type.Optional(Type.String({
|
||||||
|
maxLength: ASK_USER_TEXT_MAX_LENGTH,
|
||||||
|
description: "Optional clarification shown under the label.",
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const AskUserQuestionParams = Type.Object({
|
||||||
|
id: Type.String({
|
||||||
|
maxLength: ASK_USER_ID_MAX_LENGTH,
|
||||||
|
description: "Unique within this call; used as the answer key reported back to you.",
|
||||||
|
}),
|
||||||
|
question: Type.String({
|
||||||
|
maxLength: ASK_USER_TEXT_MAX_LENGTH,
|
||||||
|
description: "The question itself, as one plain-text line.",
|
||||||
|
}),
|
||||||
|
detail: Type.Optional(Type.String({
|
||||||
|
maxLength: ASK_USER_TEXT_MAX_LENGTH,
|
||||||
|
description: "Optional supporting context shown under the question.",
|
||||||
|
})),
|
||||||
|
options: Type.Optional(Type.Array(AskUserOptionParams, {
|
||||||
|
maxItems: ASK_USER_OPTION_LIMIT,
|
||||||
|
description: "Options to choose from. Omit only when free text is the whole answer, and then set allowOther.",
|
||||||
|
})),
|
||||||
|
allowOther: Type.Optional(Type.Boolean({
|
||||||
|
description: "Offer a free-text field alongside the options.",
|
||||||
|
})),
|
||||||
|
multiple: Type.Optional(Type.Boolean({
|
||||||
|
description: "Allow several options at once. Default: one answer per question.",
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const AskUserParams = Type.Object({
|
||||||
|
questions: Type.Array(AskUserQuestionParams, {
|
||||||
|
minItems: 1,
|
||||||
|
maxItems: ASK_USER_QUESTION_LIMIT,
|
||||||
|
description: "The questions to post, in the order the user should read them. Every question may be left unanswered.",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Shapes one schema question into the domain question; the store owns validation. */
|
||||||
|
function toQuestion(param: Static<typeof AskUserQuestionParams>): AskUserQuestion {
|
||||||
|
const { detail, options, allowOther, multiple } = param;
|
||||||
|
return {
|
||||||
|
id: param.id,
|
||||||
|
question: param.question,
|
||||||
|
...(detail === undefined ? {} : { detail }),
|
||||||
|
options: (options ?? []).map(toOption),
|
||||||
|
...(allowOther === undefined ? {} : { allowOther }),
|
||||||
|
...(multiple === undefined ? {} : { multiple }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOption(param: Static<typeof AskUserOptionParams>): AskUserQuestionOption {
|
||||||
|
const { detail } = param;
|
||||||
|
return { value: param.value, label: param.label, ...(detail === undefined ? {} : { detail }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function postedText(result: PendingAskOpenResult): string {
|
||||||
|
const count = result.ask.questions.length;
|
||||||
|
const posted = `Posted ${count.toString()} question${count === 1 ? "" : "s"} to the user as ask ${result.ask.askId}. Ending this run; the answers arrive as a follow-up message that wakes you, naming every question the user left unanswered. Do not repost these questions.`;
|
||||||
|
return result.superseded === undefined ? posted : `${posted}\n\n${renderSupersededAskText(result.superseded)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom tool that posts a question set to the user's browser as interactive UI.
|
||||||
|
*
|
||||||
|
* It deliberately does **not** await the user. Awaiting would pin the agent run
|
||||||
|
* for an unbounded human-scale wait, keep the session streaming, and leave a
|
||||||
|
* dangling tool call if the runtime were replaced meanwhile. Instead the ask
|
||||||
|
* becomes daemon-owned state, the tool terminates the run, and the submitted
|
||||||
|
* answers return later as a follow-up message that wakes the session.
|
||||||
|
*
|
||||||
|
* Rejected question sets throw: the agent loop turns the thrown message into an
|
||||||
|
* error tool result, so the model can fix the ask and post it again.
|
||||||
|
*/
|
||||||
|
export function createAskUserToolDefinition(deps: AskUserToolDeps) {
|
||||||
|
return defineTool<typeof AskUserParams, AskUserToolDetails>({
|
||||||
|
name: "ask_user",
|
||||||
|
label: "Ask user",
|
||||||
|
description: "Post a set of questions to the user as a browser form and end this run. Answers arrive later as a follow-up message; the user may leave any question unanswered.",
|
||||||
|
promptSnippet: "ask_user: post a question set to the user; ends the run, answers return as a follow-up",
|
||||||
|
promptGuidelines: [
|
||||||
|
"When you need decisions from the user, post them together with ask_user instead of asking in prose one at a time. It ends the run and the answers, including the questions the user left unanswered, come back as a follow-up message that wakes you. Call it alone and last, and do not repost the same questions or poll for answers.",
|
||||||
|
],
|
||||||
|
parameters: AskUserParams,
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const result = await deps.open({
|
||||||
|
sessionId: ctx.sessionManager.getSessionId(),
|
||||||
|
questions: params.questions.map(toQuestion),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: postedText(result) }],
|
||||||
|
details: result,
|
||||||
|
terminate: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createPiWebCustomToolDefinitions, PiSessionService } from "./piSessionService.js";
|
||||||
|
import { PendingAskStore, PendingAskValidationError } from "./pendingAskStore.js";
|
||||||
|
import { CapturingSessionEventHub, emptyArchiveStore, sessionGateway, testModelRuntime } from "./piSessionService.testSupport.js";
|
||||||
|
|
||||||
|
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||||
|
|
||||||
|
const questions = [{ id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }] }];
|
||||||
|
|
||||||
|
function askService() {
|
||||||
|
const store = new PendingAskStore({
|
||||||
|
now: () => new Date("2026-02-01T10:00:00.000Z"),
|
||||||
|
createAskId: (() => {
|
||||||
|
let next = 0;
|
||||||
|
return () => { next += 1; return `ask-${next.toString()}`; };
|
||||||
|
})(),
|
||||||
|
});
|
||||||
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
|
modelRuntime: testModelRuntime,
|
||||||
|
sessionManager: sessionGateway([]),
|
||||||
|
archiveStore: emptyArchiveStore(),
|
||||||
|
pendingAskStore: store,
|
||||||
|
askUserEnabled: true,
|
||||||
|
heartbeatIntervalMs: 60_000,
|
||||||
|
});
|
||||||
|
return { service, store };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ask_user registration", () => {
|
||||||
|
it("offers ask_user whenever the capability is configured, including to restricted tracked children", () => {
|
||||||
|
const askUser = { open: vi.fn() };
|
||||||
|
|
||||||
|
const unrestricted = createPiWebCustomToolDefinitions("/workspace", true, undefined, undefined, askUser);
|
||||||
|
const restricted = createPiWebCustomToolDefinitions("/workspace", false, undefined, undefined, askUser);
|
||||||
|
|
||||||
|
expect(unrestricted.map((definition) => definition.name)).toEqual(["edit", "ask_user"]);
|
||||||
|
expect(restricted.map((definition) => definition.name)).toEqual(["edit", "ask_user"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits ask_user when the capability is disabled", () => {
|
||||||
|
const definitions = createPiWebCustomToolDefinitions("/workspace", true);
|
||||||
|
|
||||||
|
expect(definitions.map((definition) => definition.name)).toEqual(["edit"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PiSessionService.openAsk", () => {
|
||||||
|
it("registers the question set as the session's open ask", async () => {
|
||||||
|
const { service, store } = askService();
|
||||||
|
|
||||||
|
const result = await service.openAsk({ sessionId: "session-1", questions });
|
||||||
|
|
||||||
|
expect(result.ask).toMatchObject({ askId: "ask-1", askedAt: "2026-02-01T10:00:00.000Z" });
|
||||||
|
expect(result).not.toHaveProperty("superseded");
|
||||||
|
expect(store.pendingAsk("session-1")).toMatchObject({ askId: "ask-1" });
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supersedes the session's earlier unanswered ask and reports its outcome", async () => {
|
||||||
|
const { service, store } = askService();
|
||||||
|
await service.openAsk({ sessionId: "session-1", questions });
|
||||||
|
|
||||||
|
const result = await service.openAsk({ sessionId: "session-1", questions: [{ id: "again", question: "Still?", options: [], allowOther: true }] });
|
||||||
|
|
||||||
|
expect(result.ask.askId).toBe("ask-2");
|
||||||
|
expect(result.superseded).toMatchObject({ askId: "ask-1", reason: "superseded", answeredCount: 0, unansweredIds: ["db"] });
|
||||||
|
expect(store.pendingAsk("session-1")).toMatchObject({ askId: "ask-2" });
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps asks of different sessions independent", async () => {
|
||||||
|
const { service, store } = askService();
|
||||||
|
|
||||||
|
await service.openAsk({ sessionId: "session-1", questions });
|
||||||
|
const other = await service.openAsk({ sessionId: "session-2", questions });
|
||||||
|
|
||||||
|
expect(other).not.toHaveProperty("superseded");
|
||||||
|
expect(store.pendingAsk("session-1")).toMatchObject({ askId: "ask-1" });
|
||||||
|
expect(store.pendingAsk("session-2")).toMatchObject({ askId: "ask-2" });
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unanswerable question set without opening it", async () => {
|
||||||
|
const { service, store } = askService();
|
||||||
|
|
||||||
|
await expect(service.openAsk({ sessionId: "session-1", questions: [{ id: "empty", question: "No way to answer?", options: [] }] }))
|
||||||
|
.rejects.toThrow(PendingAskValidationError);
|
||||||
|
expect(store.pendingAsk("session-1")).toBeUndefined();
|
||||||
|
await service.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -54,6 +54,8 @@ import type { SessionRouteLookup, SessionRouteRef, SessionRouteService } from ".
|
|||||||
import { type AuthChange } from "./authService.js";
|
import { type AuthChange } from "./authService.js";
|
||||||
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
||||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||||
|
import { createAskUserToolDefinition, type AskUserInvocation, type AskUserToolDeps } from "./askUserTool.js";
|
||||||
|
import { PendingAskStore, type PendingAskOpenResult } from "./pendingAskStore.js";
|
||||||
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js";
|
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js";
|
||||||
import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionCheckResult, type SubsessionReadQuery, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
|
import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionCheckResult, type SubsessionReadQuery, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
|
||||||
import { buildTranscriptView } from "./subsessionTranscript.js";
|
import { buildTranscriptView } from "./subsessionTranscript.js";
|
||||||
@@ -610,11 +612,15 @@ export function createPiWebCustomToolDefinitions(
|
|||||||
delegationEnabled: boolean,
|
delegationEnabled: boolean,
|
||||||
spawn?: SpawnSessionFn,
|
spawn?: SpawnSessionFn,
|
||||||
subsessions?: SubsessionToolDeps,
|
subsessions?: SubsessionToolDeps,
|
||||||
|
askUser?: AskUserToolDeps,
|
||||||
) {
|
) {
|
||||||
return [
|
return [
|
||||||
createPiWebEditToolDefinition(cwd),
|
createPiWebEditToolDefinition(cwd),
|
||||||
...(delegationEnabled && spawn !== undefined ? [createSpawnSessionToolDefinition(cwd, { spawn })] : []),
|
...(delegationEnabled && spawn !== undefined ? [createSpawnSessionToolDefinition(cwd, { spawn })] : []),
|
||||||
...(delegationEnabled && subsessions !== undefined ? createSubsessionToolDefinitions(cwd, subsessions) : []),
|
...(delegationEnabled && subsessions !== undefined ? createSubsessionToolDefinitions(cwd, subsessions) : []),
|
||||||
|
// Asking the user is not delegation: the questions land in the session the
|
||||||
|
// user is already watching, so tracked children may ask too.
|
||||||
|
...(askUser === undefined ? [] : [createAskUserToolDefinition(askUser)]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,12 +629,13 @@ function createDefaultRuntimeFactory(
|
|||||||
sessionManagers: Pick<PiSessionManagerGateway, "open">,
|
sessionManagers: Pick<PiSessionManagerGateway, "open">,
|
||||||
spawn?: SpawnSessionFn,
|
spawn?: SpawnSessionFn,
|
||||||
subsessions?: SubsessionToolDeps,
|
subsessions?: SubsessionToolDeps,
|
||||||
|
askUser?: AskUserToolDeps,
|
||||||
): PiWebCreateAgentSessionRuntimeFactory {
|
): PiWebCreateAgentSessionRuntimeFactory {
|
||||||
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => {
|
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => {
|
||||||
const services: AgentSessionServices = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
|
const services: AgentSessionServices = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
|
||||||
const resolvedDelegationToolsEnabled = delegationToolsEnabled
|
const resolvedDelegationToolsEnabled = delegationToolsEnabled
|
||||||
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
|
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
|
||||||
const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions);
|
const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions, askUser);
|
||||||
const result = await createAgentSessionFromServices({
|
const result = await createAgentSessionFromServices({
|
||||||
services,
|
services,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
@@ -686,6 +693,14 @@ export interface PiSessionServiceDependencies {
|
|||||||
* being exposed in releases.
|
* being exposed in releases.
|
||||||
*/
|
*/
|
||||||
subsessionsEnabled?: boolean;
|
subsessionsEnabled?: boolean;
|
||||||
|
/**
|
||||||
|
* When true, `ask_user` is available to every session, so an agent can post a
|
||||||
|
* question set to the browser. Independent of the delegation capabilities: the
|
||||||
|
* questions reach the user of the asking session, not another session.
|
||||||
|
*/
|
||||||
|
askUserEnabled?: boolean;
|
||||||
|
/** Daemon-lifetime open-ask state; defaults to an in-memory store in tests. */
|
||||||
|
pendingAskStore?: PendingAskStore;
|
||||||
/** Structured logger for notable runtime events (e.g. spawns). */
|
/** Structured logger for notable runtime events (e.g. spawns). */
|
||||||
logger?: PiSessionLogger;
|
logger?: PiSessionLogger;
|
||||||
/** Clock seam for cleanup planning tests. */
|
/** Clock seam for cleanup planning tests. */
|
||||||
@@ -748,6 +763,7 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
private readonly notificationStore: SessionNotificationStore;
|
private readonly notificationStore: SessionNotificationStore;
|
||||||
private readonly notificationGenerationBySession = new WeakMap<PiAgentSession, SessionNotificationGeneration>();
|
private readonly notificationGenerationBySession = new WeakMap<PiAgentSession, SessionNotificationGeneration>();
|
||||||
private readonly unreadStore: SessionUnreadStore;
|
private readonly unreadStore: SessionUnreadStore;
|
||||||
|
private readonly pendingAskStore: PendingAskStore;
|
||||||
private readonly catalogRefreshStatus: CatalogRefreshStatus | undefined;
|
private readonly catalogRefreshStatus: CatalogRefreshStatus | undefined;
|
||||||
private readonly unreadPublicationRetryInitialMs: number;
|
private readonly unreadPublicationRetryInitialMs: number;
|
||||||
private readonly pendingUnreadMutations: SessionUnreadMutation[] = [];
|
private readonly pendingUnreadMutations: SessionUnreadMutation[] = [];
|
||||||
@@ -768,6 +784,7 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
this.now = deps.now ?? (() => new Date());
|
this.now = deps.now ?? (() => new Date());
|
||||||
this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
|
this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
|
||||||
this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
|
this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
|
||||||
|
this.pendingAskStore = deps.pendingAskStore ?? new PendingAskStore();
|
||||||
this.catalogRefreshStatus = deps.catalogRefreshStatus;
|
this.catalogRefreshStatus = deps.catalogRefreshStatus;
|
||||||
this.unreadPublicationRetryInitialMs = Math.max(
|
this.unreadPublicationRetryInitialMs = Math.max(
|
||||||
0,
|
0,
|
||||||
@@ -787,6 +804,7 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
|
check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
|
||||||
read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile),
|
read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile),
|
||||||
},
|
},
|
||||||
|
deps.askUserEnabled === true ? { open: (input) => this.openAsk(input) } : undefined,
|
||||||
);
|
);
|
||||||
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
|
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
|
||||||
this.workspaceActivity = deps.workspaceActivity;
|
this.workspaceActivity = deps.workspaceActivity;
|
||||||
@@ -1068,6 +1086,19 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
return { sessionId: created.id, cwd: decision.cwd };
|
return { sessionId: created.id, cwd: decision.cwd };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the question set an agent wants the user to answer as the session's
|
||||||
|
* open ask. Deliberately does not wait for the user: `ask_user` terminates the
|
||||||
|
* run and the submitted answers come back later as a follow-up message.
|
||||||
|
*
|
||||||
|
* Rejected question sets throw {@link PendingAskValidationError}, which the
|
||||||
|
* agent loop reports to the model as an error tool result.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/require-await -- async so a rejected question set becomes a rejection rather than a synchronous throw from a promise-returning method.
|
||||||
|
async openAsk(input: AskUserInvocation): Promise<PendingAskOpenResult> {
|
||||||
|
return this.pendingAskStore.open(input);
|
||||||
|
}
|
||||||
|
|
||||||
/** Summaries of the tracked subsessions spawned by `parentSessionId`. */
|
/** Summaries of the tracked subsessions spawned by `parentSessionId`. */
|
||||||
async listSubsessions(parentSessionId: string, parentSessionFile?: string): Promise<SubsessionSummary[]> {
|
async listSubsessions(parentSessionId: string, parentSessionFile?: string): Promise<SubsessionSummary[]> {
|
||||||
const parentFile = nonEmptyString(parentSessionFile);
|
const parentFile = nonEmptyString(parentSessionFile);
|
||||||
|
|||||||
Reference in New Issue
Block a user