feat(agent): ask_user tool via MCP elicitation, multi-channel routing#56
Merged
Conversation
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>
6 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
AskUserQuestionschema 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
Behavior per channel
Limits
Test plan
🤖 Generated with Claude Code