Commit Graph

139 Commits

Author SHA1 Message Date
rcourtman dc0635606d Keep Pulse runtime boundaries model-owned 2026-05-17 18:24:13 +01:00
rcourtman 6f37c8d0ef Align discovery prompt governance
- keep fallback Assistant governance aligned with discovery run support

- describe Patrol discovery as read-or-refresh evidence access
2026-05-15 23:11:59 +01:00
rcourtman 6aad1118cd Align discovery with tool-led AI runtime
- add a forced run action to pulse_discovery for known resources

- make discovery progress describe model-backed evidence analysis rather than a live Assistant chat

- keep shared select hydration stable for persisted discovery intervals
2026-05-15 23:05:36 +01:00
rcourtman 303a8e2fe8 Remove Assistant handoff decision guidance 2026-05-15 16:57:25 +01:00
rcourtman 482b84e219 Remove Assistant pre-model context heuristics 2026-05-15 16:28:17 +01:00
rcourtman 99fce8e5a3 Remove Assistant status routing residue 2026-05-15 15:19:26 +01:00
rcourtman 0561a1b5fa Remove Assistant and Patrol tool-routing heuristics 2026-05-15 14:51:15 +01:00
rcourtman eac7dfe9ef Make Patrol reasoning model-owned 2026-05-15 12:22:52 +01:00
rcourtman 895b78bd89 Make Assistant tool choice model-owned 2026-05-15 11:27:15 +01:00
rcourtman 348582df66 Fix Assistant chat model-owned routing 2026-05-15 10:50:23 +01:00
rcourtman e36945741e Sanitise plain-JSON tool-call leaks from weak local models
Small Ollama models (qwen2.5:11b, qwen2.5:14b, similar) frequently emit
Pulse tool invocations as plain JSON inside content instead of routing
through the structured tool_calls channel. Users saw raw payloads like
`{"name": "pulse_query", "parameters": {...}}` as the assistant's final
response.

Extend cleanToolCallArtifacts and containsToolCallMarker with a new pass
that detects this leak shape, gated on a closed allowlist of canonical
tool names sourced from the runtime registry. The allowlist auto-syncs
when registerTools() gains a tool, so no separate hand-maintained list.

Anchored on (?:^|\n) and a leading `"name"` key so prose containing JSON
fragments, unrelated objects (`{"foo":"bar"}`), or named resources
(`{"name":"my-vm","cpu":50}`) are left untouched.
2026-05-11 17:02:07 +01:00
rcourtman 2a9afb1112 Sanitise double-pipe DeepSeek DSML tool-call markers in chat
Found by exercising pulse_summarize in real chat: the user asked a
question, the model called the tool successfully (response came back
with narrative_source: ai), but the chat panel ended with raw DSML
text and "Assistant response is ready" — no actual prose answer.

Root cause was in agentic_sanitize.go. The fast-path string list
only checked single-pipe DSML variants ("<|DSML|...>"), but
deepseek-v4-flash emits the double-pipe form ("<||DSML||...>").
The opening sequence didn't match any marker, so cleanToolCallArtifacts
returned the content unchanged. The chat orchestrator then showed
the raw DSML to the user as if it were the assistant's answer.

Fix:
- Add double-pipe variants (Unicode and ASCII) to the fast-path
  marker list. Six new entries, mirroring the existing single-pipe
  entries.
- Add a backstop regex (dsmlRe) that matches any pipe-count variant
  via `</?[\||]+/?DSML[\||]*`. Future model behaviour with triple
  or higher pipe counts gets caught without another fast-path edit.
- containsToolCallMarker gets the same coverage so streaming
  detection stops forwarding content the moment the marker
  appears, regardless of pipe count.

Tests in agentic_sanitize_test.go gain three new cases for cleanup
(double-pipe Unicode, double-pipe ASCII, triple-pipe regex
backstop) and two for detection (double-pipe Unicode, double-pipe
ASCII). All passing.

Process note: this bug was invisible to existing tests because the
test suite only covered the single-pipe variants that were
documented in the marker list. The double-pipe form only appears
when an actual model emits it. Same pattern as the JSON-casing fix
earlier — exercise the surface against a real LLM, find what tests
in isolation can't see.
2026-05-11 13:42:11 +01:00
rcourtman 3b6fc3ef31 Audit chat-package Chat/ChatStream callers for cost recording
The parent-package audit (TestCostRecordingCoverage in
internal/ai/cost_recording_audit_test.go) only walks
internal/ai/*.go — sub-packages were unguarded, which is the gap
that let the chat cost-recording bug land in the first place (the
agentic loop in internal/ai/chat/ called ChatStream without anyone
recording cost; fixed in a0b3bc7ed).

Adds a parallel audit at internal/ai/chat/cost_recording_audit_test.go
covering both .Chat() and .ChatStream() callers. Same shape as the
parent audit but extended to include the streaming variant, since
the chat package is built around the agentic loop's ChatStream
call site.

The chat package's orchestrator/loop split means recording lives
in chat.Service.recordChatTurnCost — the orchestrator that owns
the loop — rather than inside the loop methods that call
ChatStream. Two doc-comment exemptions cover that:
  - AgenticLoop.ExecuteWithTools / executeWithTools (agentic.go)
  - AgenticLoop.ensureFinalTextResponse (agentic_final.go)
Both name the orchestrator that records, so a future contributor
moving recording elsewhere has a clear pointer to update.

A new ChatStream caller added to the chat package without either
recording or an exemption marker will now fail this audit, the
same way the parent audit would have caught QuickAnalysis or
Narrate if they had been written without recording.
2026-05-11 09:16:26 +01:00
rcourtman 9f43a22fb1 Bound patrol-main session at 200 messages to stop unbounded disk growth
The patrol-main chat session was reused across every scheduled
Patrol run with no upper bound. After a month of runs the file
had grown to 16 MB / 3,593 messages, and every AddMessage
rewrote the whole file to disk — so the I/O cost per Patrol
run was scaling with total session age, not with the run's own
output. Across all chat sessions on this dev instance, the
ai_sessions directory hit 676 MB / 1,629 files.

The stateless-Patrol-input fix (commit 43760fb0d) stopped
loading the session back into the agentic loop, but Patrol
still wrote each run's messages to the session for the Pulse
Assistant sidebar's forensic view. That write path is what
this commit bounds.

ExecutePatrolStream now calls SessionStore.TrimMessages(200)
after each run, keeping roughly the last two runs' worth of
messages — enough for the sidebar to show recent activity, far
short of unbounded growth. The next Patrol run on a bloated
session will drop the historical 3,000+ messages down to 200
on its first write, so existing storage debt clears on its
own without a separate migration.

User-driven chat sessions are unaffected: TrimMessages with
keepMostRecent <= 0 is a no-op, and callers that want full
history retention simply don't call it. Only Patrol's
forensic session is capped.

The canonical Patrol forensic log is the PatrolRunRecord
history surfaced at /api/ai/patrol/runs — that's the durable
record with structured fields. The chat-session-shaped file
is a sidebar convenience, not the source of truth.

Three tests guard the boundary:
  - TrimMessages keeps the most recent N (50 messages
    trimmed to 10 → messages 40-49 remain)
  - TrimMessages is a no-op below threshold (5 messages,
    cap 200 → 5 messages remain)
  - TrimMessages with non-positive keep is a no-op (3
    messages, cap 0 or -5 → 3 messages remain)

ai-runtime contract updated.
2026-05-10 23:19:08 +01:00
rcourtman a0b3bc7ed3 Record user-chat token usage to the cost ledger
chat.Service.ExecuteStream was a long-standing cost-ledger gap: the
agentic loop accumulated token counts via stream callbacks (see
GetTotalInputTokens / GetTotalOutputTokens in agentic_control.go)
and surfaced them in the SSE done envelope to the frontend, but
nothing on the server side recorded a cost.UsageEvent. Patrol,
discovery, QuickAnalysis, and the report narrators all record; only
chat — the bulk of AI token spend — did not. The operator's AI
usage dashboard was therefore understating cost dramatically.

Found while extending the cost-recording mindset across subpackages
after fixing QuickAnalysis (08491b9f4). Initially spawned as a
separate task but the right shape and scope became clear, so landing
it directly here.

Pipeline:
- Service.CostStore() exposes the per-tenant cost store handle.
- chat.Config gains optional CostStore *cost.Store field, threaded
  into chat.Service.costStore at NewService time.
- chat.Service.recordChatTurnCost records a UsageEvent with
  UseCase="chat" after every loop.ExecuteWithTools return (success
  OR error — operator was billed regardless of clean response).
  Skips when costStore is nil or zero tokens accumulated.
- ai_handler.go's two chatCfg construction sites populate CostStore
  via h.resolveCostStore(ctx).
- router wires the resolver to AISettingsHandler.GetAIService(ctx).CostStore()
  with no Enabled gate — even brief chat usage while AI was being
  configured should appear in the dashboard.

ExecutePatrolStream is deliberately not changed. It creates a
separate tempLoop and its caller (patrol_ai.go) records cost via
its own helper at line 887. Recording in ExecuteStream only avoids
double-counting on the patrol-via-chat path.

Tests in chat/cost_recording_test.go cover: recording when store
configured, no-op when store nil, no-op on zero tokens (early
failures), graceful handling of model strings missing the
provider prefix.
2026-05-10 23:15:53 +01:00
rcourtman 99c499ade7 Repair orphan tool_calls in convertToProviderMessages
Defense-in-depth for the malformed-history bug pattern. The
Patrol fix made patrol-main runs stateless, but Assistant
chat sessions are inherently multi-turn and must keep their
history. Any chat session that ends mid-tool-call — network
drop, ctx timeout, browser crash, uncaught panic, any
interrupt that fires between "model emits tool_calls" and
"agentic loop appends all tool results" — leaves the
persisted session with orphan tool_call_ids. The next message
that loads this history is rejected with the same provider
error that flapped Patrol for 33 days:

  An assistant message with 'tool_calls' must be followed by
  tool messages responding to each 'tool_call_id'.

For Patrol this was fixable by ignoring the session. For
Assistant it isn't; the conversation context is the product.

convertToProviderMessages now ends with a repairOrphanToolCalls
pass that scans every assistant message with tool_calls and
inserts synthetic is_error tool result messages immediately
after the assistant turn for any tool_call_id that has no
matching downstream result. The synthetic content is marked
is_error=true and explains the interruption so the model can
retry the same call or proceed without that data — preserving
conversational continuity while satisfying the provider's
structural-validity check.

This guards every conversation that crosses
convertToProviderMessages, not just Assistant chat. If Patrol
ever changes back to loading session history, the same safety
net applies. If a new entry point appears for some other LLM
flow, it gets the repair for free.

Three tests guard the boundary:
  - Orphan injection (3 tool_calls, only 1 result → 2
    synthetic results, marked is_error with interrupted
    explanation, ordering preserved)
  - Clean no-op (all tool_calls fulfilled → no synthetic
    messages, no is_error pollution)
  - Existing truncation test still passes (assistant message
    with both tool_calls and own tool_result → no repair
    needed, tool_call_id matches in same message)

ai-runtime contract updated.
2026-05-10 23:10:13 +01:00
rcourtman 03463c1bfe Thread per-tenant AI narrators into pulse_summarize via chat session
v1 of pulse_summarize (1fe5d6853) shipped with heuristic narrative
only. The follow-up wiring promised in that commit now lands: the
chat session carries optional report-narration providers that the
tool's handler reads when building requests, so AI-narrated synthesis
flows into chat using the same provider, sanitizer, model selection,
cost ledger, and budget gate the report PDF endpoint already uses.

Pipeline:
- pkg/reporting Narrator / FleetNarrator / FindingsProvider interfaces
  are already implemented by internal/ai.Service. No new
  implementations.
- tools.ExecutorConfig + PulseToolExecutor gain three optional fields
  (ReportNarrator, ReportFleetNarrator, ReportFindingsProvider).
  Clone() copies them so per-session executors inherit the wiring.
- chat.Config gains the same three fields; NewService threads them
  into ExecutorConfig.
- tools_summarize.go reads e.reportNarrator/FleetNarrator/
  FindingsProvider and populates MetricReportRequest /
  MultiReportRequest. The engine already accepts these on the request
  and falls back to heuristic when they are nil — no engine changes
  needed.
- AIHandler gains SetReportNarratorResolver(ctx -> narrators); both
  per-tenant and default chat.Config construction sites invoke the
  resolver. Router wires the resolver to AISettingsHandler.GetAIService
  with the same Enabled-gate the reporting handler uses.

Unconfigured tenants are unchanged: the resolver returns nil, the
tool returns heuristic narrative — identical to today. Configured
tenants get AI synthesis in chat that matches what their report PDF
already carries, billed and budget-gated the same way.
2026-05-10 22:50:17 +01:00
rcourtman 43760fb0d0 Patrol runs are now stateless — drop prior session history
Patrol's "patrol-main" session was reused across every
scheduled run, so ExecutePatrolStream loaded the full
session history into the agentic loop's input. When any
prior run ended after the model emitted tool_calls but
before all tool results landed (provider error, timeout,
context cancellation), the orphan tool_calls were persisted
and every subsequent run inherited them. The provider then
rejected the conversation with:

  An assistant message with 'tool_calls' must be followed
  by tool messages responding to each 'tool_call_id'.
  (insufficient tool messages following tool_calls message)

Patrol failed 271 consecutive runs with this error before
it was diagnosed. Each new run added another user prompt
on top of the broken structure, so the message slice grew
to 33+ messages with one assistant turn at position 23
holding 4 orphan tool_call_ids and 9 user prompts stacked
after it.

The "patrol runs need a clean slate" comment at line 1898
documents that the knowledge accumulator is freshened per
run; the conversation history was the matching gap.
ExecutePatrolStream now passes only this run's user prompt
to the agentic loop. The session is still written to for
audit/forensics, just no longer fed back into the model.

Live verified: Patrol now completes successfully (18 tool
calls, 3m23s) on a session that previously failed every
run with the malformed-history error. The runtime-failure
finding auto-resolved on this same successful run.

Adds a classifier bucket for "insufficient tool messages"
errors so any future regression in this area surfaces with
a meaningful diagnostic instead of the generic
"Provider analysis error" fallback. New
PatrolFailureCauseMalformedToolHistory cause; predicate
patrolMalformedToolHistory matches DeepSeek's exact phrasing
and OpenAI's similar variants.

ai-runtime contract updated.
2026-05-10 17:14:47 +01:00
rcourtman 797edfc6ab Preserve Patrol recommendation reasons in sessions 2026-05-08 15:33:14 +01:00
rcourtman 3e9cefafaf Structure Patrol Assistant next-step metadata 2026-05-08 15:01:09 +01:00
rcourtman 6d080a4810 Restore Patrol Assistant next-step links 2026-05-08 14:51:50 +01:00
rcourtman 475429def2 Preserve Patrol assessment action kind 2026-05-08 01:40:50 +01:00
rcourtman a5e8a1edb6 Restore Patrol recommendation in Assistant sessions 2026-05-08 01:08:53 +01:00
rcourtman dd15d23a35 Hydrate Patrol requester in Assistant handoffs
- hydrate approval requester identity into backend handoff actions
- include requester provenance in refreshed finding action context
- document the backend requester authority boundary
2026-05-08 00:26:04 +01:00
rcourtman cf8931031d Record Patrol fix approvals in action audit
- seed queued investigation fixes with governed action plans

- persist planned and pending lifecycle evidence for Patrol approvals

- cover the approval adapter and subsystem contracts
2026-05-07 23:26:39 +01:00
rcourtman d9331d39e4 Move Patrol run Assistant handoffs server-side
- rehydrate Patrol run context from Patrol history for chat requests and follow-up sessions
- send only browser-safe run metadata from the frontend
- classify and redact provider runtime failures in handoff prompts and briefings
2026-05-07 21:58:06 +01:00
rcourtman 7392f17190 Preserve Patrol handoff session identity
Keep Patrol assessment and configuration failure Assistant handoffs distinct from generic scoped context or single-finding sessions.
2026-05-07 19:46:49 +01:00
rcourtman ff9cb5c133 Restore Patrol run Assistant session identity
Refs #1463
2026-05-07 15:53:59 +01:00
rcourtman a3973f19ee Use current approval state in Assistant handoffs 2026-05-07 13:09:42 +01:00
rcourtman 8594297b18 Refresh Assistant handoff session summaries 2026-05-07 12:51:27 +01:00
rcourtman 7d28defe6a Expose safe Assistant handoff session summaries 2026-05-07 12:17:04 +01:00
rcourtman 7aaf76d03c Carry live approval lifecycle into Assistant handoffs 2026-05-07 10:25:14 +01:00
rcourtman a886399e59 Clamp scoped chat handoffs to controlled execution 2026-05-07 01:39:50 +01:00
rcourtman c389e403ca Apply request approval mode to chat executor 2026-05-07 01:37:30 +01:00
rcourtman 39a5be31a0 Sanitize Assistant briefing handoffs 2026-05-06 21:01:55 +01:00
rcourtman 8db5bd39f7 Hydrate Assistant resource state context 2026-05-06 20:30:20 +01:00
rcourtman 8d51556d92 Hydrate Assistant action audit context 2026-05-06 20:05:42 +01:00
rcourtman 2832bd321e Invalidate stale Assistant handoff context 2026-05-06 19:36:05 +01:00
rcourtman 50fee63c9e Refresh Assistant handoff finding context 2026-05-06 19:16:57 +01:00
rcourtman 0b6768feb5 Resolve handoff timelines through canonical resources 2026-05-06 19:06:30 +01:00
rcourtman 209ea8dfdb Hydrate Assistant handoff resource relationships 2026-05-06 19:02:07 +01:00
rcourtman 7df4588f63 Hydrate Assistant handoff resource policy context 2026-05-06 18:55:23 +01:00
rcourtman 05f47560de Hydrate Assistant handoff resource timeline context 2026-05-06 18:39:46 +01:00
rcourtman 8c60e48f09 Hydrate Assistant handoff approval status 2026-05-06 18:33:22 +01:00
rcourtman 5c8070c582 Persist Assistant handoff action references 2026-05-06 18:26:21 +01:00
rcourtman f38f3a5409 Persist Assistant handoff resource references 2026-05-06 18:15:58 +01:00
rcourtman aeede4fb2f Hydrate Assistant handoff resource scope 2026-05-06 17:59:55 +01:00
rcourtman 25f9172e2e Preserve Patrol handoff context across Assistant turns 2026-05-06 17:46:14 +01:00
rcourtman 7de85ff257 Keep Patrol handoff context model-only in Assistant 2026-05-06 17:37:19 +01:00
rcourtman 53838d8f93 Fix RC3 backend release blockers 2026-05-01 21:36:28 +01:00