MemberJunction / MemberJunction/MJ

feat(ai-agents): stream Loop agents' final reply as live text in the chat

Open
#3,220 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
TSQL
Stars
29
Forks
6
Avg merge
2d 1h
Merged PRs (30d)
323

Description

## Summary

Follow-up to #3080. That PR added the conversation client's render path for
`kind:'final-response'` stream chunks but deliberately left core agents un-streamed —
a Loop agent's reply is born *inside* its streamed JSON turn envelope, so its raw chunks
aren't renderable. This PR closes that gap **server-side, with zero client changes**:
a per-turn incremental parser extracts the reply text from the streaming envelope and
re-emits it tagged. It is **opt-in per agent**: a new `AIAgent.EnableFinalResponseStreaming`
flag (BIT, default OFF — mirroring the `AllowMemoryWrite`/`SupportsPlanMode` gate pattern)
must be explicitly enabled before an agent streams, via a new "Stream Final Response"
config card on the AI Agent form (next to Expose as Action) or metadata. Flip it on Sage
and Sage types its answer into the chat live; leave it off and behavior is byte-identical
to today.

## How it works

- **`LoopAgentStreamExtractor`** (new, `@memberjunction/ai-agents`): incremental
tokenizer over the streamed envelope. It emits the root-level `message` string's
unescaped content as deltas — **only once `taskComplete: true` is known**. The loop
system prompt orders `taskComplete` before `message`, so in the common case finality
is known before the answer streams and the reply types in token-by-token; if a model
emits `message` first, the text buffers and flushes the moment `taskComplete: true`
is parsed (graceful degradation — appears at once). Handles JSON string escapes split
across chunk boundaries (including `\uXXXX`), markdown fences / prose around the
envelope, and ignores anything after the closing brace.
- **`BaseAgentType.CreateFinalResponseStreamExtractor()`** (new virtual, default
`null`): agent types opt in by returning a fresh per-turn extractor. Only
`LoopAgentType` implements it today; Flow/other types are unaffected.
- **`BaseAgent`'s prompt-streaming bridge**: for the **root agent only**, extracted
text is re-emitted as *additional* tagged deltas (plus a closing tagged chunk); raw
envelope chunks keep flowing unchanged for existing consumers (e.g. the AI Test
Harness). Sub-agent turns never extract — a sub-agent's "final" message is a result
for its parent, not the user's reply. Parallel prompt execution binds extraction to
the first streaming model so interleaved envelopes can't corrupt parsing.
- **"Stream Final Response" UI switch** (`ng-core-entity-forms`): a config card on the
custom AI Agent form (beside "Expose as Action") exposing the flag as a checkbox, so
developers opt an agent in from the standard management UI — toggling takes effect on
the next run with no server restart (the engine cache updates on entity save). The
generated form variant carries the field as well via the CodeGen artifacts.
- **`AgentStreamChunkKind`** exported from `ai-core-plus` as the source-of-truth union
for the `kind` discriminator (review follow-up from #3080), plus two small notes from
that review: a staleness comment on the render callback's cached message, and a
streamed-rendering section in `guides/CONVERSATIONS_UX_STACK_GUIDE.md`.

## Safety / compatibility

- **Never emits for:** non-final turns (`taskComplete: false`), sub-agent turns,
`message` keys nested inside other values (e.g. `payloadChangeRequest`), or empty
messages. A run with `onStreaming` unset creates no extractor at all (which also
keeps external producers that manage their own tagged stream — e.g. Betty's compose
action — free of double-emission).
- **Raw chunks are untouched** — this is purely additive on the wire; consumers that
read untagged chunks see exactly what they saw before.
- One additive migration: `AIAgent.EnableFinalResponseStreaming BIT NOT NULL DEFAULT 0`
(+ CodeGen artifacts). Default OFF means merged-but-unflagged deployments are
byte-identical to today. Changeset included (ai-agents, ai-core-plus,
core-entities, server minor; ng-conversations, ng-core-entity-forms patch).

## Testing

An independent adversarial review of the initial commit (parser probed empirically with
10 adversarial inputs; bridge, client interaction, and all invocation paths traced) found
one blocker and two should-fixes, all resolved in the hardening commit: the extractor is
now recreated at each validation-retry attempt boundary (a truncated attempt could bleed
parser state into the retry's envelope — garbage text until completion repaint); a
trailing unpaired high surrogate (emoji escapes split across chunks) is held back so
emitted text is always well-formed UTF-16; and malformed `\uXXXX` escapes no longer
swallow structural characters (which could leak the next field's text). Known scope
boundaries are documented in the fileoverview (Chat-type clarifying questions
deliberately don't stream; pre-empted `taskComplete:true` turns stream transiently and
self-correct via completion reconciliation; same-model parallel execution is a
documented limitation pending a task discriminator on the chunk contract).

- **19 extractor tests**: chunk-boundary robustness (down to 1-char feeds),
escapes split across chunks, field-order inversion (buffer + flush), non-final
turns, nested `message` keys, fences/trailing prose, quotes-and-braces inside other
string values, empty/no message, whitespace-heavy formatting, envelope-close
finalization, and the LoopAgentType factory override.
- Full suites green across all touched/adjacent packages: ai-agents (1,657),
ai-core-plus (210), ng-conversations (868), MJServer (668), conversations-runtime
(116), core-entity-forms (41). Full repo suite: 591/591 tasks (branch merged with
latest `next` first). The new migration was additionally validated by executing it
end-to-end against a live database.
- Manually verified in MJExplorer, including at the transport level: with the flag ON,
`StreamingContent` frames tagged `final-response` stream over the PubSub websocket and
render live; with the flag OFF, zero tagged frames are emitted — toggled via the UI
checkbox with no server restart (the engine cache updates on save).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---

## Source

Converted from PR #3136 (deprioritized — branch preserved, PR closed).

- **Pull request:** https://github.com/MemberJunction/MJ/pull/3136
- **Branch:** [`colin-feat-loop-agent-stream-text`](https://github.com/MemberJunction/MJ/tree/colin-feat-loop-agent-stream-text)
- **Original author:** @cmbrock-BC

The branch has **not** been deleted. To resume this work, check out `colin-feat-loop-agent-stream-text` and reopen a PR.

/cc @cmbrock-BC @AN-BC @rkihm-BC @topherHunnewell

Contributor guide

Open the contributing guide

Research direction

Resume the preserved colin-feat-loop-agent-stream-text branch or inspect PR #3136 first. Review LoopAgentStreamExtractor, BaseAgent, LoopAgentType, the AIAgent migration and the 19 extractor tests, then verify the listed package suites and UI behavior. Done means the original PR is reopened or its remaining scope is clearly identified.

Written by the indexing model from the issue text.

Assessment

Tech stack
sql, typescript
Domain
ai, backend-api-design, databases, frontend, testing
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Clearly specified
Newbie friendliness
15/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.