Skip to content

feat(agent): ask_user tool via MCP elicitation, multi-channel routing#56

Merged
meidad merged 1 commit into
mainfrom
feat/ask-user-via-mcp-elicitation
May 27, 2026
Merged

feat(agent): ask_user tool via MCP elicitation, multi-channel routing#56
meidad merged 1 commit into
mainfrom
feat/ask-user-via-mcp-elicitation

Conversation

@meidad
Copy link
Copy Markdown
Collaborator

@meidad meidad commented May 27, 2026

Summary

Gives the Nomos agent a multiple-choice question primitive analogous to Claude Code's AskUserQuestion — but routed through Nomos's channel layer so the same agent can ask via Slack buttons on the default channel and numbered text + reply parsing everywhere else.

The Claude Agent SDK already exposes the AskUserQuestion schema as a built-in tool, but the rendering lives in the Claude Code CLI's TUI — useless to our headless daemon. The SDK-native path for headless hosts is MCP elicitation (`onElicitation` callback + `extra.sendRequest` from the tool handler). We use that.

What's new

File Role
src/sdk/ask-user.ts New `ask_user(question, options[], header?)` MCP tool. Builds an `elicitation/create` request with a `oneOf` schema and calls `extra.sendRequest`. Returns the chosen option label as a text result, or a "user did not answer" message on decline/timeout/cancel so the agent can continue gracefully.
src/daemon/elicitation-manager.ts Host-side handler. Tracks pending elicitations keyed by elicitation id + channel; auto-declines after 10 min. Slack gets Block Kit buttons via the new `postBlocks()` helper; everything else gets numbered text and matches user replies (numeric, exact label, or unambiguous substring) before they reach the agent.
src/sdk/session.ts New `onElicitation` param on `RunSessionParams`; passed through to `query()`.
src/daemon/agent-runtime.ts Per-turn `onElicitation` callback scoped to the incoming message's channel; new optional `source` arg threaded into `runAgent()` (3 call sites). `setElicitationManager()` / `getElicitationManager()` accessors.
src/daemon/gateway.ts Instantiates `ElicitationManager` after the channel manager, hands it to the runtime, and intercepts incoming messages to consume them as elicitation answers when one is pending.
src/daemon/channels/slack-user.ts New `postBlocks()` helper for Block Kit rendering; new `app.action(/^ask_user_option:\d+$/)` handler that resolves via `mgr.resolveByButton()` and replaces the original question with a "✅ You chose: X" acknowledgement.
src/sdk/tools.ts Registers `askUserTool` alongside `propose_plan` in the `nomos-memory` MCP server.

Behavior per channel

  • Slack default channel — Block Kit blocks with buttons (one per option), descriptions as context blocks. Button click resolves instantly; original message is replaced with "✅ You chose: X".
  • iMessage / Telegram / Discord / email / CLI — numbered text (`1. option label — description`) with a hint to reply with the number or label. Matched via gateway intake interceptor: numeric (`"1"`, `"(2)"`), exact label (case-insensitive), or unambiguous substring. Ambiguous substrings fall through to the agent. The agent doesn't see the consumed reply.
  • All channels — 10-minute TTL; on timeout the agent's `await` resolves with `action: "decline"` so the tool returns a "user didn't answer, continue without input" message.

Limits

  • Single-select only in this pass. Multi-select needs a different Slack widget (checkbox group) and an array schema. Easy to add.
  • One pending question per channel — a second cancels the first (logged + agent gets `action: "cancel"`).
  • Slack rendering only via SlackUserAdapter's `postBlocks()`; SlackPollingAdapter falls through to the generic numbered-text path.
  • MCP SDK referenced via plain shapes rather than importing `ElicitRequestFormParamsSchema` directly — the `@modelcontextprotocol/sdk` package is only a transitive dep here.

Test plan

  • Restart daemon; confirm log line shows `nomos-google-workspace` and other MCP servers still register cleanly (no regressions on existing tools).
  • In a chat: ask the agent something open-ended like "help me organize my morning routine" and observe whether it uses `ask_user` naturally. (Alternative: prompt explicitly with "use the ask_user tool to ask me which calendar to add an event to.")
  • On Slack default channel: verify the question renders with buttons, click one, confirm the message updates to "You chose: X" and the agent's next reply incorporates the choice.
  • On a non-Slack channel (iMessage / CLI / wherever): verify the question renders as numbered text, reply with `"2"` and with the label text, confirm both work.
  • Trigger an unanswered question and wait 10 minutes — confirm the agent's next response acknowledges the timeout gracefully.
  • Trigger a second question on the same channel before answering the first — confirm the first is cancelled and the second renders.

🤖 Generated with Claude Code

Gives the agent a multiple-choice question primitive analogous to
Claude Code's `AskUserQuestion` but routed through Nomos's channel
layer so the same agent can ask via Slack buttons on the default
channel, numbered text + reply parsing elsewhere.

The Claude Agent SDK already exposes the `AskUserQuestion` schema as a
built-in tool, but the rendering lives in the Claude Code CLI's TUI —
useless to our headless daemon. The SDK-native path for headless hosts
is MCP elicitation (`onElicitation` callback + server.elicit() via the
tool handler's `extra.sendRequest`). We use that.

Pieces:

- src/sdk/ask-user.ts — new `ask_user(question, options[], header?)`
  MCP tool. Handler builds an `elicitation/create` request with a
  `oneOf` schema and calls `extra.sendRequest`. Returns the chosen
  option label as a text result, or a "user did not answer" message
  on decline/timeout/cancel so the agent can continue gracefully.
- src/daemon/elicitation-manager.ts — host-side handler. Tracks
  pending elicitations keyed by elicitation id and channel,
  auto-declines after 10 min. Slack DM gets Block Kit buttons via
  the SlackUserAdapter's new `postBlocks()`; everything else gets a
  numbered text message and matches user replies (numeric/exact/
  unambiguous-substring) before they reach the agent.
- src/sdk/session.ts — new `onElicitation` param on
  `RunSessionParams`, passed through to the SDK's `query()`.
- src/daemon/agent-runtime.ts — per-turn `onElicitation` callback
  scoped to the incoming message's channel; new optional `source`
  arg threaded into `runAgent()`.
- src/daemon/gateway.ts — instantiates `ElicitationManager` after the
  channel manager, hands it to the runtime, and intercepts incoming
  messages to consume them as elicitation answers when one is pending.
- src/daemon/channels/slack-user.ts — new `postBlocks()` helper for
  Block Kit rendering; new `app.action(/^ask_user_option:\d+$/)`
  handler that resolves via `mgr.resolveByButton()` and replaces the
  original question with a "✅ You chose: X" acknowledgement.
- src/sdk/tools.ts — registers `askUserTool` alongside `propose_plan`
  in the `nomos-memory` MCP server.

Limits: single-select only in this pass (multi-select needs a
different Slack widget + array schema), one pending question per
channel (a second cancels the first), MCP SDK types referenced via
plain shapes rather than importing the schema (the package is only a
transitive dep here).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@meidad meidad merged commit bd259eb into main May 27, 2026
6 checks passed
meidad added a commit that referenced this pull request May 27, 2026
#57)

A grab-bag of fixes for issues that surfaced after #56 landed. Three
themes:

1) Slack channels weren't usable after workspace reconnect
   - /api/slack/channels was reading the workspace's `secrets` column
     raw from SQL, but the integrations table encrypts at rest. Added
     a `decryptSecret()` step so the channel list loads again.
   - SlackPollingAdapter and SlackUserAdapter now emit a structured
     `account_inactive` diagnostic with the exact fix (reinstall the
     Slack app at api.slack.com/apps + Reconnect in Settings). The
     `onAuthError` callback grew an `{kind, reason}` info argument so
     gateway can broadcast `auth_error` events to the UI with proper
     context.
   - At adapter boot, probe the default channel via
     conversations.info on both bot AND user tokens. Surface different
     diagnostics for "wrong workspace token / stale default" vs "bot
     not in channel".
   - `sendAsAgent` retries with the user client when the bot post
     fails with channel_not_found (the common "forgot to /invite
     @nomos to this channel" case). Agent stops going dark.
   - Validate the user token's team_id matches the workspace's
     teamId at boot — catches OAuth callbacks that wrote the wrong
     token to a per-workspace row.

2) Google Workspace "Make default" wasn't persisting
   - The integrations table's `metadata` column was being
     double-encoded via `JSON.stringify({...})::jsonb`, producing
     JSONB STRINGS like `"{\"is_default\":true}"` instead of JSONB
     OBJECTS. Every `metadata->>'is_default'` lookup returned NULL,
     so the PATCH's `jsonb_set` was a no-op on existing rows.
   - Fixed all writers to use `sql.json({...})`, and switched the
     PATCH to overwrite metadata wholesale instead of jsonb_set —
     wholesale replace self-heals legacy bad rows on the next click.
   - /api/google/status now reads the manifest first (source of
     truth), so it agrees with /api/google/accounts and the make-
     default change shows up immediately.

3) Model registry made dynamic + corrected to match Anthropic docs
   - New `src/sdk/model-capabilities.ts` (and a settings-side mirror)
     with the full lineup: Opus 4.7/4.6/4.5, Sonnet 4.6/4.5, Haiku
     4.5. Per-model `contextWindow`, `maxOutputTokens`, family.
   - Corrected to match docs.claude.com/en/docs/about-claude/models:
     Opus 4.7, Opus 4.6, and Sonnet 4.6 are 1M-context by default
     (no beta needed). Older Opus / Sonnet 4.5 / Haiku 4.5 stay at
     200K. Also fixed maxOutputTokens (Opus 4.7/4.6: 128K, Haiku
     4.5: 64K).
   - /admin/context Model Limits panel now renders from the registry
     with the current model highlighted, and reports the actual
     context window from the running config (NOMOS_MODEL +
     NOMOS_BETAS via /api/admin/context).
   - Settings page model picker and /model slash-command picker
     derive their model lists from the registry too — add a new
     model in one place, it shows up everywhere.

Bonus: `NOMOS_BETAS` is now mapped to the `app.betas` config row
(both settings/lib/env.ts and src/db/app-config.ts) so future betas
can be exposed in UI without another schema change. The 1M-context
beta toggle was scaffolded then removed in this same commit once we
confirmed it's not needed for any current model.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant