Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/daemon/agent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,22 @@ export class AgentRuntime {
private slackWorkspaces?: Array<{ teamId: string; teamName: string; userId: string }>;
private notificationDefault?: { platform: string; channelId: string; label?: string };

// Optional elicitation manager (set by gateway). When present, the SDK's
// `onElicitation` callback routes ask_user requests through it.
private elicitationManager?: import("./elicitation-manager.ts").ElicitationManager;

private initialized = false;

/** Wire in the elicitation manager. Called by the gateway after construction. */
setElicitationManager(mgr: import("./elicitation-manager.ts").ElicitationManager): void {
this.elicitationManager = mgr;
}

/** Expose the elicitation manager so channel adapters can resolve answers. */
getElicitationManager(): import("./elicitation-manager.ts").ElicitationManager | undefined {
return this.elicitationManager;
}

/** Get the configured model name. */
getModel(): string {
return this.config?.model ?? "claude-sonnet-4-6";
Expand Down Expand Up @@ -685,6 +699,11 @@ export class AgentRuntime {
sessionKey,
userState,
personaPrompt,
{
platform: message.platform,
channelId: message.channelId,
threadId: message.threadId,
},
);

// Cache the new SDK session ID
Expand Down Expand Up @@ -746,6 +765,11 @@ export class AgentRuntime {
sessionKey,
userState,
personaPrompt,
{
platform: message.platform,
channelId: message.channelId,
threadId: message.threadId,
},
);

if (result.sessionId) {
Expand Down Expand Up @@ -785,6 +809,11 @@ export class AgentRuntime {
sessionKey,
userState,
personaPrompt,
{
platform: message.platform,
channelId: message.channelId,
threadId: message.threadId,
},
);
if (upgraded.sessionId) {
this.sdkSessionIds.set(sessionKey, upgraded.sessionId);
Expand Down Expand Up @@ -824,6 +853,12 @@ export class AgentRuntime {
sessionKey?: string,
userState?: string,
personaPrompt?: string,
/**
* Source channel of the incoming message — used so the elicitation
* manager renders `ask_user` questions back on the user's active
* channel. Optional so non-message runs (cron, internal) keep working.
*/
source?: { platform: string; channelId: string; threadId?: string },
): Promise<{
text: string;
sessionId?: string;
Expand Down Expand Up @@ -855,6 +890,16 @@ export class AgentRuntime {
systemPromptAppend = systemPromptAppend + "\n\n" + personaPrompt;
}

// Build the elicitation callback for this turn. The `ask_user` MCP
// tool calls `extra.sendRequest({method: "elicitation/create"})`;
// the SDK forwards to `onElicitation`, we route to the channel the
// user is currently talking to us on, and return their answer.
const mgr = this.elicitationManager;
const onElicitation: import("../sdk/session.ts").RunSessionParams["onElicitation"] =
mgr && source
? (request, opts) => mgr.handleElicitation(request, source, opts.signal)
: undefined;

const sdkQuery = runSession({
prompt,
model: model ?? this.config.model,
Expand All @@ -869,6 +914,7 @@ export class AgentRuntime {
anthropicBaseUrl: this.config.anthropicBaseUrl,
plugins: this.plugins,
useSubscription: this.config.useSubscription,
onElicitation,
stderr: (data: string) => {
// Log SDK subprocess stderr so we can diagnose crash reasons
const trimmed = data.trim();
Expand Down
53 changes: 53 additions & 0 deletions src/daemon/channels/slack-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ export class SlackUserAdapter implements ChannelAdapter {
private teamName: string | null = null;
private onMessage: (msg: IncomingMessage) => void;
private draftManager: DraftManager;
// Optional elicitation manager — wired in by gateway after construction.
// Used by the ask_user button action handler to resolve pending requests.
private elicitationManager?: import("../elicitation-manager.ts").ElicitationManager;

/** Inject the elicitation manager. Called by gateway after adapter creation. */
setElicitationManager(mgr: import("../elicitation-manager.ts").ElicitationManager): void {
this.elicitationManager = mgr;
}

// Default channel -- the user's direct chat channel with the agent
private defaultChannelId: string | null = null;
Expand Down Expand Up @@ -263,6 +271,25 @@ export class SlackUserAdapter implements ChannelAdapter {
});
});

// ask_user button clicks. action_id is `ask_user_option:<index>`;
// value is `<elicitation-id>::<option-index>`. The elicitation
// manager resolves the pending request and we replace the original
// question with a "you chose X" acknowledgement.
this.app.action(/^ask_user_option:\d+$/, async ({ action, ack, respond }) => {
await ack();
const value = (action as { value?: string }).value;
if (!value) return;
const mgr = this.elicitationManager;
if (!mgr) return;
const { resolved, label } = mgr.resolveByButton(value);
if (resolved && label) {
await respond({
replace_original: true,
text: `:white_check_mark: You chose: *${label}*`,
});
}
});

// Edit draft: open a modal with the draft content for editing
this.app.action("edit_draft", async ({ action, ack, body }) => {
await ack();
Expand Down Expand Up @@ -429,6 +456,32 @@ export class SlackUserAdapter implements ChannelAdapter {
return result.ts;
}

/**
* Post a message with Block Kit blocks. Used by the elicitation manager
* to render `ask_user` questions with interactive buttons. Falls back to
* plain text on platforms that ignore blocks. Same default-channel
* guard as postMessage — we only render interactive UIs in the channel
* the user actually watches.
*/
async postBlocks(
channelId: string,
fallbackText: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
blocks: any[],
threadId?: string,
): Promise<string | undefined> {
if (channelId !== this.defaultChannelId) return undefined;
const client = this.clientFor(channelId);
if (!client) return undefined;
const result = await client.chat.postMessage({
channel: channelId,
text: fallbackText,
blocks,
thread_ts: threadId,
});
return result.ts;
}

/**
* Update an existing message (for streaming support).
* Only works in the default channel (matches postMessage guard).
Expand Down
Loading
Loading