v0.6.4.0 feat(assistant): action-intent routing through decision pipeline (#148 v1)#153
Merged
Merged
Conversation
…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>
There was a problem hiding this comment.
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 andActionRouterport to keep@skytwin/assistantdecoupled from decision/db deps. - Wired
POST /api/assistant/messagesto attempt intent routing before the LLM call and persist an assistant message withmetadata.intentRoutewhen 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. |
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
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:
False-positive guards (the load-bearing test surface):
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:
Route branching
`POST /api/assistant/messages` now tries intent classification BEFORE the LLM call. If routed:
If no intent matches OR the router throws (graceful degradation), falls through to the existing LLM chat path.
Web
Test plan
Phase 2 epic — done
🤖 Generated with Claude Code