Skip to content

v0.6.4.0 feat(assistant): action-intent routing through decision pipeline (#148 v1)#153

Merged
jayzalowitz merged 1 commit into
mainfrom
jayzalowitz/assistant-actions
May 5, 2026
Merged

v0.6.4.0 feat(assistant): action-intent routing through decision pipeline (#148 v1)#153
jayzalowitz merged 1 commit into
mainfrom
jayzalowitz/assistant-actions

Conversation

@jayzalowitz

Copy link
Copy Markdown
Owner

Summary

Closes #148 v1 — the last phase-2 piece for the assistant epic. Saying "archive that email" or "schedule a meeting with X" in chat now creates an `ApprovalRequest` on the existing `#/approvals` page. Conversational messages still go through the LLM chat path unchanged.

Phase 2 epic complete: #146 streaming + #147 context + #149 multi-turn + #148 action routing.

Conservative v1 design choice

Chat-driven actions ALWAYS land in approvals, never auto-execute. Even when `DecisionMaker.evaluate()` returns `autoExecute=true`, the chat router collapses that to `requiresApproval=true` for v1.

Why: chat is a free-text channel. An unintended intent match (regex firing on a message ABOUT scheduling instead of asking to schedule) cannot trigger a real send / spend / modify. The `/api/events` route has structured signals as ground truth; chat doesn't.

The user already has the `#/approvals` UI. Routing through it preserves the audit trail and feedback loop unchanged.

Phase 2 of #148 lifts this restriction once we have an LLM-classifier confidence score AND an explicit per-user opt-in.

Safety Invariants — all upheld

What landed

Rule-based intent classifier (`@skytwin/assistant`)

Pure regex/keyword matching for a small vocabulary:

Intent Pattern Maps to
`archive_email` "archive that/this/the/it" `email_triage`
`label_email` "label/tag that as X" (captures label) `email_triage`
`send_reply` "reply to / respond to / send a reply" `email_triage`
`create_event` "schedule/book/set up a meeting/call" `calendar_invite`
`decline_event` "decline/skip/cancel that meeting" `calendar_update`
`create_task` "remind me to / add a task to" `task_management`

False-positive guards (the load-bearing test surface):

  • Returns null for messages < 8 chars ("ok", "thanks", "sure")
  • Returns null for meta-discussion patterns ("how do you decide when to schedule things?")
  • Returns null for action verbs without an object phrase ("I have a large archive of files")

Pipeline wiring

`buildActionRouter()` constructs the same `TwinService + PolicyEvaluator + DecisionMaker + ExplanationGenerator` stack as `events.ts`. Reuses the issue #122 `LabelInferencePort` so chat-driven `label_email` candidates get the same learned-from-history confidence boost.

The route adapter:

  1. Builds a synthetic `DecisionObject` (`chat_intent_`, rawData includes `triggerMessage`).
  2. Persists the decision (FK target for downstream rows).
  3. Builds full `DecisionContext` — same shape as events.ts (preferences + patterns + traits + temporalProfile, fetched in parallel).
  4. Calls `DecisionMaker.evaluate(context)` — every safety gate fires here.
  5. Generates + persists `ExplanationRecord`.
  6. If `selectedAction`: persists `ApprovalRequest` (filters `accessToken` + `rawData` from parameters).
  7. Emits `approval:new` SSE so the existing badge updates immediately.

Route branching

`POST /api/assistant/messages` now tries intent classification BEFORE the LLM call. If routed:

  • Persists a structured assistant message with `metadata.intentRoute` carrying the outcome.
  • Both sync JSON and SSE response shapes supported. SSE flushes `thread` + `user` + `done` in one shot — no chunk events because no streaming text was generated.

If no intent matches OR the router throws (graceful degradation), falls through to the existing LLM chat path.

Web

  • `pages/assistant.js:renderMessages` checks `m.metadata.intentRoute` and renders an action footer:
    • `requires-approval` → "Open approval →" link to `#/approvals`
    • `blocked` → muted "Action blocked by your safety policy" notice
  • `assistant.css` — small accent-colored footer styles using existing theme variables.
  • Empty-state copy updated to mention the new capability.

Test plan

  • `pnpm build` — 20 packages green
  • `pnpm test` — 40 packages green; 16 new tests (12 intent classifier + 4 routeIntent)
  • `pnpm lint` — clean
  • Manual: configure provider, navigate to `#/assistant`, type "archive that email". Verify (a) approval lands in `#/approvals`, (b) chat shows "Open approval" footer, (c) the bubble explains what was queued.
  • Manual: type "how does archiving work?" — verify it falls through to the LLM chat reply (false-positive guard working).
  • Manual: with trust tier OBSERVER (which blocks most actions), verify the chat surfaces "Action blocked by your safety policy" instead of an approval link.
  • Manual: stop CRDB, type an intent — verify chat falls through to LLM reply (graceful degradation), no crash.

Phase 2 epic — done

Issue Title Status
#146 SSE streaming ✅ shipped
#147 Twin profile + memory context ✅ shipped
#149 Multi-turn LlmClient API ✅ shipped
#148 Action-intent routing ✅ this PR (v1; phase 2 follow-ups noted)

🤖 Generated with Claude Code

…line (#148 v1)

Closes #148 v1 — final phase-2 piece for the assistant epic. Chat now
routes detected action intents ("archive that email", "schedule a
meeting") through the existing decision pipeline. Saying an action in
chat creates an ApprovalRequest on the existing #/approvals page;
conversational messages still go through the LLM chat path unchanged.

Conservative v1 — chat-driven actions ALWAYS land in approvals, never
auto-execute, even when the engine returns autoExecute=true. Free-text
is too ambiguous to bypass the approval step on the first cut. Phase 2
of #148 lifts this when we have an LLM-confidence score + per-user
opt-in.

Safety invariants — all upheld:
- #1 (no auto-exec without policy): every intent runs through
  DecisionMaker.evaluate() → PolicyEvaluator.evaluate(). No bypass.
- #2 (always log explanations): ExplanationGenerator.generate()
  persists for every chat-driven decision. Persist failure logs but
  doesn't abort.
- #3 (trust tiers): pulled from user record, never from chat input.
- #4-#7 inherited from DecisionMaker.

Pieces:
- @skytwin/assistant: detectIntent (rule-based regex/keyword classifier,
  tolerant to short/ambiguous messages, false-positive guarded) +
  ActionRouter port + AssistantService.routeIntent. Package stays free
  of decision-engine + db deps.
- apps/api/src/routes/assistant.ts: buildActionRouter() factory wires
  TwinService + PolicyEvaluator + DecisionMaker + ExplanationGenerator
  + LabelInferencePort. Synthetic DecisionObject from chat intent.
  Persists ApprovalRequest. Emits approval:new SSE.
- POST /messages branches on intent BEFORE the LLM call. Both sync JSON
  and SSE response paths supported.
- pages/assistant.js: action footer renders approval-link or blocked
  notice based on metadata.intentRoute. CSS styled with theme variables.

Tests: 16 new (12 intent classifier + 4 routeIntent). Full suite green
across 40 packages; lint clean.

Phase 2 epic is now complete: #146 streaming + #147 context + #149
multi-turn + #148 action routing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 5, 2026 20:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR completes assistant phase 2c (#148 v1) by adding rule-based action-intent detection on chat messages and routing matched intents through the existing decision/policy/explanations pipeline, always creating an ApprovalRequest (never auto-executing) and surfacing the result in the assistant UI.

Changes:

  • Added a rule-based detectIntent() classifier and ActionRouter port to keep @skytwin/assistant decoupled from decision/db deps.
  • Wired POST /api/assistant/messages to attempt intent routing before the LLM call and persist an assistant message with metadata.intentRoute when routed.
  • Updated the web assistant page to render an “Open approval” footer (or “blocked” notice) and bumped version/changelog for 0.6.4.0.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
VERSION Bumps release version to 0.6.4.0.
CHANGELOG.md Documents assistant action-intent routing v1 and safety posture.
packages/assistant/src/intent-classifier.ts Adds regex-based intent detection producing an ActionIntent.
packages/assistant/src/action-router.ts Introduces ActionRouter port and ActionRouteOutcome contract.
packages/assistant/src/assistant-service.ts Adds routeIntent() to classify + route before LLM reply (graceful degradation).
packages/assistant/src/index.ts Re-exports intent/router API.
packages/assistant/src/__tests__/intent-classifier.test.ts Adds positive + negative (false-positive guard) tests for classifier.
packages/assistant/src/__tests__/assistant-service.test.ts Adds routeIntent() behavior tests.
apps/api/src/routes/assistant.ts Implements buildActionRouter() and routes matched intents ahead of LLM.
apps/web/public/js/pages/assistant.js Renders action footer based on metadata.intentRoute.
apps/web/public/css/assistant.css Styles the action footer/link.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +242 to +249
const decision: DecisionObject = {
id: `chat_intent_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
situationType: intent.situationType as SituationType,
domain: intent.domain,
urgency: 'medium',
summary: intent.summary,
rawData: { ...intent.rawData, triggerMessage: intent.triggerMessage },
interpretedAt: new Date(),
Comment on lines +299 to +303
// No selected action OR every candidate was denied → blocked.
if (!outcome.selectedAction) {
return {
kind: 'blocked',
reason: outcome.reasoning || 'No suitable action could be taken right now.',
Comment on lines +311 to +315
const {
accessToken: _omitToken,
rawData: _omitRawData,
...visibleParameters
} = (outcome.selectedAction.parameters ?? {}) as Record<string, unknown>;
Comment on lines +62 to +64
* lookup failure). The `AssistantService.routeIntent` caller catches
* that and returns `{ kind: 'no-action' }` so the chat falls through
* to the LLM reply rather than blowing up the whole turn.
@jayzalowitz jayzalowitz merged commit a4c74e0 into main May 5, 2026
12 checks passed
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.

Assistant phase 2c: action-intent routing through @skytwin/decision-engine

2 participants