create-qpq-app 0.1.24 → 0.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/template/docusaurus/docs/actions/core/ai/ask-ai-prompt-stream.md +2 -2
- package/template/docusaurus/docs/actions/core/ai/ask-ai-prompt.md +3 -0
- package/template/docusaurus/docs/actions/core/system/ask-get-runtime-remaining-time.md +52 -0
- package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-event-append.md +3 -3
- package/template/docusaurus/docs/actions/features/event-doc-ai/active-chat-state.md +3 -2
- package/template/docusaurus/docs/actions/features/event-doc-ai/ask-event-doc-ai-process-continue.md +46 -0
- package/template/docusaurus/docs/actions/features/event-doc-ai/ask-event-doc-ai-process-send.md +6 -25
- package/template/docusaurus/docs/actions/features/event-doc-ai/ask-event-doc-ai-stream-turn.md +67 -0
- package/template/docusaurus/docs/actions/features/event-doc-ai/chat-list-state.md +2 -1
- package/template/docusaurus/docs/actions/features/event-doc-ai/streaming-and-status-state.md +7 -6
- package/template/docusaurus/docs/actions/features/web-socket-queue/ask-service-request.md +15 -0
- package/template/docusaurus/docs/config/core/storage-drive.md +1 -0
- package/template/docusaurus/docs/config/features/event-doc-ai.md +4 -2
- package/template/docusaurus/docs/config/features/event-doc.md +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-qpq-app",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.25",
|
|
4
4
|
"description": "Scaffold a new quidproquo app: npx create-qpq-app my-app",
|
|
5
5
|
"main": "./lib/commonjs/index.js",
|
|
6
6
|
"module": "./lib/esm/index.js",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/node": "^22.13.13",
|
|
58
|
-
"quidproquo-tsconfig": "0.1.
|
|
58
|
+
"quidproquo-tsconfig": "0.1.25"
|
|
59
59
|
},
|
|
60
60
|
"bin": {
|
|
61
61
|
"create-qpq-app": "./lib/commonjs/bin/createQpqApp.js"
|
|
@@ -42,7 +42,7 @@ The parameters are identical to [askAiPrompt](./ask-ai-prompt.md) — see there
|
|
|
42
42
|
| --- | --- | --- |
|
|
43
43
|
| `model` | `AiModel` | Which model to prompt. |
|
|
44
44
|
| `prompt` | `string` | The user prompt. Ignored when `options.messages` is set. |
|
|
45
|
-
| `options` | `AskAiPromptStreamOptions` | `{ system?, aiName?, messages?, reasoning?, caching? }` — same shape and meaning as [`AskAiPromptOptions`](./ask-ai-prompt.md#askaipromptoptions). |
|
|
45
|
+
| `options` | `AskAiPromptStreamOptions` | `{ system?, aiName?, messages?, reasoning?, caching?, maxDurationMs?, maxSteps?, maxOutputTokens? }` — same shape and meaning as [`AskAiPromptOptions`](./ask-ai-prompt.md#askaipromptoptions). |
|
|
46
46
|
|
|
47
47
|
## Returns
|
|
48
48
|
|
|
@@ -92,7 +92,7 @@ Within a step, text / reasoning / tool-input events arrive as matched `*Start
|
|
|
92
92
|
| `stop` | `stop` | The model completed its answer naturally. |
|
|
93
93
|
| `length` | `length` | The response hit the output token limit. |
|
|
94
94
|
| `contentFilter` | `content-filter` | The provider's content filter stopped the response. |
|
|
95
|
-
| `toolCalls` | `tool-calls` | Generation stopped while the model still had tool calls in flight. On the final `Finish` part this means the turn was halted early by a stop condition (
|
|
95
|
+
| `toolCalls` | `tool-calls` | Generation stopped while the model still had tool calls in flight. On the final `Finish` part this means the turn was halted early by a stop condition (`maxSteps` or `maxDurationMs`) rather than finishing naturally, and can be resumed by re-sending the recorded history. |
|
|
96
96
|
| `error` | `error` | The stream errored. |
|
|
97
97
|
| `other` | `other` | The provider reported a reason outside this catalog. |
|
|
98
98
|
| `unknown` | `unknown` | The provider reported no reason, or one this version does not recognise. |
|
|
@@ -50,6 +50,9 @@ function* askAiPrompt(
|
|
|
50
50
|
| `messages` | [`AiMessage[]`](#aimessage) | – | A full conversation history. When present, this is sent instead of `prompt`, letting you carry a multi-turn dialogue (including prior assistant turns and tool results). |
|
|
51
51
|
| `reasoning` | [`AiReasoningConfig`](#aireasoningconfig) | – | Enables extended thinking. Its presence turns reasoning on; `budgetTokens` caps how many tokens the model may spend thinking before it answers (defaults to `4096` on AWS). |
|
|
52
52
|
| `caching` | `boolean` | – | Marks the system prompt and the last message (or the last `messages` entry) with a Bedrock cache point, so a following call in the same conversation can read everything up to there from cache instead of reprocessing it. |
|
|
53
|
+
| `maxSteps` | `number` | – | Cap on model/tool steps in one call. Unset means no cap: the loop runs until the model stops on its own or `maxDurationMs` trips. A client-side tool call (a tool with no executor) still halts it immediately. |
|
|
54
|
+
| `maxOutputTokens` | `number` | provider default | Output token cap per model call. Bedrock defaults to 8192, which a reasoning block plus a large tool input can exceed; the step then finishes with `length` and the tool call arrives truncated. Raise it for agentic workloads (Claude Sonnet allows 64k). |
|
|
55
|
+
| `maxDurationMs` | `number` | – | Wall-clock budget for the tool loop. Checked between steps, so the loop can overrun by one step; leave headroom. When it trips with tool calls still outstanding the result finishes with `toolCalls`, and re-sending the recorded history resumes the turn. Pair it with [askGetRuntimeRemainingTime](../system/ask-get-runtime-remaining-time.md) to stop before the platform deadline. |
|
|
53
56
|
|
|
54
57
|
### `AiModel`
|
|
55
58
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: askGetRuntimeRemainingTime
|
|
3
|
+
description: How many milliseconds the current execution has left before the platform kills it.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# askGetRuntimeRemainingTime
|
|
7
|
+
|
|
8
|
+
Returns the number of **milliseconds** left before the platform terminates the current execution. Use it inside long-running loops (an agentic AI turn, a large batch sweep) to stop cleanly and hand off before the hard cutoff.
|
|
9
|
+
|
|
10
|
+
- **Action type:** `SystemActionType.GetRuntimeRemainingTime`
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
import { askGetRuntimeRemainingTime } from 'quidproquo-core';
|
|
14
|
+
|
|
15
|
+
const HANDOFF_HEADROOM_MS = 60_000;
|
|
16
|
+
|
|
17
|
+
export function* askProcessUntilDeadline(items: string[]) {
|
|
18
|
+
for (const item of items) {
|
|
19
|
+
if ((yield* askGetRuntimeRemainingTime()) < HANDOFF_HEADROOM_MS) {
|
|
20
|
+
return { done: false, resumeFrom: item };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
yield* askProcessItem(item);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return { done: true };
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Signature
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
function* askGetRuntimeRemainingTime(): AskResponse<number>;
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Parameters
|
|
37
|
+
|
|
38
|
+
None.
|
|
39
|
+
|
|
40
|
+
## Returns
|
|
41
|
+
|
|
42
|
+
`number` — milliseconds remaining. It counts down across the execution, so reading it later in the same story returns a smaller value.
|
|
43
|
+
|
|
44
|
+
## Notes
|
|
45
|
+
|
|
46
|
+
- On AWS Lambda this is the invocation's `getRemainingTimeInMillis()`, so it reflects the function's configured timeout (queue processors run for up to 15 minutes).
|
|
47
|
+
- Runtimes with no execution limit (plain Node, the browser, the dev server) return `Number.MAX_SAFE_INTEGER`. Code that reads it should treat any large value as "no deadline" rather than comparing against a fixed budget.
|
|
48
|
+
- Leave headroom. The check only runs where you put it, so keep enough time for the current step to finish plus whatever save or re-enqueue the handoff needs.
|
|
49
|
+
|
|
50
|
+
## Related
|
|
51
|
+
|
|
52
|
+
- [askGetRuntimeCorrelation](./ask-get-runtime-correlation.md) — the other per-execution runtime value.
|
|
@@ -9,7 +9,7 @@ Appends a single client-authored event to a document's ordered event stream —
|
|
|
9
9
|
|
|
10
10
|
The append is **expected-version optimistic concurrency**: it resolves the log's current head with a consistent read (and, when `options.validate` is true and the collection has registered validation functions, the document state at that head), validates the event against that state, then writes it at `head + 1` with a **conditional** write. Two writers that resolved the same head race for that slot; exactly one wins, and the other gets back the namespaced Upsert `Conflict`. A losing lap does not start over — it folds only the handful of events that beat it onto the state it already holds, re-validates, and claims the new head + 1 — and retries up to a bounded number of times before giving up (see [Notes](#notes)).
|
|
11
11
|
|
|
12
|
-
**Validation against the resolved state happens here, at append time, when enabled.** Dedup (a repeated `clientMessageId`) and version monotonicity are still decided when the log is folded, against the accepted events before the one in question, and the fold's own acceptance rules remain in place as defence in depth. But the collection's registered `validateEvent` runs against the exact state the event will land on, before the write — the gate that stops a bad event from ever entering the log, not just from being read back.
|
|
12
|
+
**Validation against the resolved state happens here, at append time, when enabled.** Dedup (a repeated `clientMessageId`) and version monotonicity are still decided when the log is folded, against the accepted events before the one in question, and the fold's own acceptance rules remain in place as defence in depth. But the collection's registered `validateEvent` runs against the exact state the event will land on, before the write — the gate that stops a bad event from ever entering the log, not just from being read back. After a pass, the event is also folded onto that same state as a check that the fold can actually read it (an unregistered or missing schema version throws): the log is append-only, so an event the fold can't read would otherwise be stored permanently and make the document unreadable. A caller that passes `{ validate: false }` (e.g. trusted server-authored appends) skips both and behaves as write-and-go instead.
|
|
13
13
|
|
|
14
14
|
- **Built from:** `askDateNow`, `askEventDocAppendBaseResolve` / `askEventDocAppendBaseAdvance` (head + state resolution), `askEventDocValidateAppend`, `askEventDocEventWrite`, and `askRetry` (plus, when the collection configures `onPublish`/`onAppend`, `askEventDocGetByIdOrThrow`, `askEventDocHookStates`, and `askInlineFunctionExecute`). Not a single action.
|
|
15
15
|
- **Does not maintain the summary record itself.** The queryable summary is rebuilt from the log by the events store's stream projector ([`onStream`](../../../config/features/event-doc-summary.md)), so it is eventually (not immediately) consistent with a just-written event.
|
|
@@ -55,7 +55,7 @@ function* askEventDocEventAppend(
|
|
|
55
55
|
| `modelId` | `string` | The document id whose log the event is appended to. The base resolve throws `NotFound` when the log has no head to append after — every real log opens with `INIT_STATE`, so a missing document (not an empty log) is what this catches. |
|
|
56
56
|
| `input` | `EventDocEventInput` | The client-authored event envelope — see below. |
|
|
57
57
|
| `actor` | `EventDocEventActor` | Who authored the event; stamped onto the event as `createdBy`. Usually obtained from [askEventDocResolveActor](./ask-event-doc-resolve-actor.md). |
|
|
58
|
-
| `options.validate` | `boolean` | Default `true`. Whether to resolve the document state at the append's head
|
|
58
|
+
| `options.validate` | `boolean` | Default `true`. Whether to resolve the document state at the append's head, run the collection's registered `validateEvent`, and fold the candidate onto that state as a readability check before the write. The append route (the client trust boundary) leaves this on; [askEventDocAppendServerEvent](./ask-event-doc-append-server-event.md) passes `false` for trusted server-authored writes, since the fold remains their gate. |
|
|
59
59
|
|
|
60
60
|
### `EventDocEventInput`
|
|
61
61
|
|
|
@@ -90,7 +90,7 @@ What the client POSTs to append an event. `modelId` and the server-stamped prove
|
|
|
90
90
|
## Notes
|
|
91
91
|
|
|
92
92
|
- **Dedup and version monotonicity are still decided at fold time**, against the accepted events before the one in question: a repeated `clientMessageId` is ignored, and an event whose version is older than the log's highest accepted version is ignored. The append does not check either.
|
|
93
|
-
- **Domain/lifecycle validation now runs at append time too, when enabled.** When `options.validate` is `true
|
|
93
|
+
- **Domain/lifecycle validation now runs at append time too, when enabled.** When `options.validate` is `true`, the event is checked against the document state at the head it will land on, before the write; a rejection throws `ErrorTypeEnum.Invalid` and nothing is written. It is then folded onto that same state — a schema version the fold has no reducer for (or any other fold failure) also throws `ErrorTypeEnum.Invalid` and writes nothing, rather than landing an unreadable event in the append-only log. `{ validate: false }` skips both checks and falls back to write-and-go.
|
|
94
94
|
- **Write contention is expected and retried, not treated as a bug.** [askEventDocEventWrite](./ask-event-doc-event-write.md)'s conditional (`ifNotExists`) write is the slot two writers that resolved the same head race for; the loser gets `KeyValueStoreUpsertErrorTypeEnum.Conflict`, folds just the events that beat it onto the state it already holds, re-validates, and re-laps at the new head. Retries are bounded (`EVENT_DOC_APPEND_MAX_RETRIES`, with linear backoff and jitter); exhausting them throws `ErrorTypeEnum.Conflict` — sustained contention on one document means something is hammering it, not ordinary concurrent editing. Different documents are different partition keys and never contend with each other.
|
|
95
95
|
- **Log order is commit order.** Because the id is the log's next contiguous position rather than a value minted independently by each writer, `afterEventId` cursors and snapshot positions (`upToEventId`) are exact — no two events can claim the same position, and there is no "arbitrary but stable" ordering case to reason about.
|
|
96
96
|
- **Does not maintain the summary record.** The summary is rebuilt from the log by the events store's stream projector, so it lags a just-written event until the stream delivers.
|
|
@@ -9,7 +9,7 @@ These `ask`-generators are **client-side UI state setters** for the Event Doc AI
|
|
|
9
9
|
|
|
10
10
|
Each setter dispatches a typed **effect** through the core State action processors: internally it calls `askStateDispatchEffect` → `askStateDispatch`, which the SPA's state runtime folds into `EventDocAiState` via `eventDocAiReducer` (a `buildEffectReducer` over `EventDocAiEffect`). In a browser SPA the State domain is wired through the client state store (see `defineStateDispatchOverWebsockets` / `askStateDispatch` in `quidproquo-core`), so a `yield*` here is a synchronous, local state update. Every setter returns `AskResponse<void>`.
|
|
11
11
|
|
|
12
|
-
`chatMessages` holds only **finalized** turns. The live, in-flight assistant reply lives separately in `
|
|
12
|
+
`chatMessages` holds only **finalized** turns. The live, in-flight assistant reply lives separately in `streamSegments` (see [Event Doc AI streaming & status state](./streaming-and-status-state.md)) and is folded into a finalized message once the reply completes.
|
|
13
13
|
|
|
14
14
|
## State shape
|
|
15
15
|
|
|
@@ -19,7 +19,8 @@ type EventDocAiState = {
|
|
|
19
19
|
activeChatId: Nullable<string>;
|
|
20
20
|
|
|
21
21
|
chatMessages: EventDocAiChatMessage[];
|
|
22
|
-
|
|
22
|
+
streamSegments: EventDocAiMessageSegment[];
|
|
23
|
+
isStreaming: boolean;
|
|
23
24
|
|
|
24
25
|
isLoadingChats: boolean;
|
|
25
26
|
isLoadingHistory: boolean;
|
package/template/docusaurus/docs/actions/features/event-doc-ai/ask-event-doc-ai-process-continue.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: askEventDocAiProcessContinue
|
|
3
|
+
description: Resume a chat turn a previous execution handed off before its runtime deadline.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# askEventDocAiProcessContinue
|
|
7
|
+
|
|
8
|
+
Resumes a turn that an earlier execution handed off. It loads the saved chat history, which already ends on the partial assistant reply, and runs [askEventDocAiStreamTurn](./ask-event-doc-ai-stream-turn.md) as a continuation so the model picks up from its own recorded tool calls and results. This is the story behind the `<storeName>AiChatContinue` service function that [defineEventDocAi](../../../config/features/event-doc-ai.md) registers.
|
|
9
|
+
|
|
10
|
+
The service function entry (`eventDocAiChatContinue`) is invoked async with the requesting session, so the websocket connection, correlation, storage scope, and actor all carry over. It rebuilds the eventDocAi context around this story, then either sends the result to the original correlation with `askServiceRequestRespond`, or returns silently if the turn was handed off again.
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
import { askEventDocAiProcessContinue } from 'quidproquo-features';
|
|
14
|
+
|
|
15
|
+
export function* askResumeTurn(docId: string, chatId: string) {
|
|
16
|
+
return yield* askEventDocAiProcessContinue(docId, chatId);
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Signature
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
function* askEventDocAiProcessContinue(
|
|
24
|
+
docId: string,
|
|
25
|
+
chatId: string,
|
|
26
|
+
lengthResumes: number,
|
|
27
|
+
): AskResponse<EventDocAiChatSendResult | ServiceRequestDeferred>;
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Parameters
|
|
31
|
+
|
|
32
|
+
| Parameter | Type | Description |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| `docId` | `string` | The document the chat is scoped to. Trusted: it came from the handing-off execution's context, not from a client. |
|
|
35
|
+
| `chatId` | `string` | The chat to resume. |
|
|
36
|
+
| `lengthResumes` | `number` | Consecutive resumes the output token cap has caused so far, carried on the continuation payload. |
|
|
37
|
+
|
|
38
|
+
## Returns
|
|
39
|
+
|
|
40
|
+
See [askEventDocAiStreamTurn](./ask-event-doc-ai-stream-turn.md#returns).
|
|
41
|
+
|
|
42
|
+
## Related
|
|
43
|
+
|
|
44
|
+
- [askEventDocAiProcessSend](./ask-event-doc-ai-process-send.md) — the turn that hands off.
|
|
45
|
+
- [askEventDocAiStreamTurn](./ask-event-doc-ai-stream-turn.md) — the shared streaming and handoff.
|
|
46
|
+
- [askServiceFunctionExecute](../../webserver/service-function/ask-service-function-execute.md) — the async invoke that starts a continuation.
|
package/template/docusaurus/docs/actions/features/event-doc-ai/ask-event-doc-ai-process-send.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: askEventDocAiProcessSend
|
|
3
|
-
description: The backend chat turn — persist the user message, stream the model's reply
|
|
3
|
+
description: The backend chat turn — persist the user message, then stream the model's reply, handing off to a continuation before the runtime deadline.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# askEventDocAiProcessSend
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Starts one conversational turn on the backend. It validates the attachments, appends the user's message to the chat history and **saves** immediately (so a refresh mid-reply still shows the question), then runs [askEventDocAiStreamTurn](./ask-event-doc-ai-stream-turn.md), which streams the reply and hands the turn to the continuation service function when the runtime deadline gets in the way. This is the handler behind the `ChatSend` websocket method (`onChatSend`).
|
|
9
9
|
|
|
10
|
-
- Built from [
|
|
10
|
+
- Built from [askEventDocAiAttachmentsValidate](./ask-event-doc-ai-attachments-validate.md), the history load/save helpers, and [askEventDocAiStreamTurn](./ask-event-doc-ai-stream-turn.md).
|
|
11
11
|
|
|
12
12
|
```typescript
|
|
13
13
|
import { askEventDocAiProcessSend } from 'quidproquo-features';
|
|
@@ -40,31 +40,12 @@ function* askEventDocAiProcessSend(
|
|
|
40
40
|
|
|
41
41
|
## Returns
|
|
42
42
|
|
|
43
|
-
`EventDocAiChatSendResult
|
|
44
|
-
|
|
45
|
-
## What one turn does
|
|
46
|
-
|
|
47
|
-
1. Reads the AI name, model, and reasoning budget from the feature's globals, and resolves the system prompt — freshest source first: the configured `systemPromptGenerator` inline function (run per-turn with `{ docId }` so it can carry live document state), else the static `systemPrompt`, else a built-in default. The prompt is never persisted.
|
|
48
|
-
2. [Validates the attachments](./ask-event-doc-ai-attachments-validate.md) against the document's storage drive and trusted `docId`.
|
|
49
|
-
3. Loads the chat history, appends the new user message (attachments first, then text), and **saves** immediately — so a refresh mid-reply still shows the question.
|
|
50
|
-
4. Runs one or more rounds (bounded, currently up to 20):
|
|
51
|
-
1. Streams the reply with [askAiPromptStream](../../core/ai/ask-ai-prompt-stream.md). File segments in the history become drive-referenced file parts (no URLs), which the action processor resolves to contents at prompt time. Tools do **not** receive `docId` from the model — executors inherit the session context and read the trusted id there. From the second round on, a transport-only nudge message (never saved to history) is appended so the conversation doesn't end on an assistant turn.
|
|
52
|
-
2. Maps over the stream with [askStreamMap](../../core/stream/ask-stream-map.md), dispatching each part to the UI as it arrives (the live typing view) and collecting the parts.
|
|
53
|
-
3. Folds the collected parts into durable segments (`text`/`reasoning` deltas merged, tool calls paired with results). A stream that produced no content (e.g. it errored before any text) saves no assistant message.
|
|
54
|
-
4. If there are segments, **saves** the finalized assistant message and dispatches it to the UI as the finalized message.
|
|
55
|
-
5. Clears the UI's now-superseded live-stream buffer.
|
|
56
|
-
6. If the round's [`finishReason`](../../core/ai/ask-ai-prompt-stream.md#aistreamfinishreasonenum) was `toolCalls` (halted early) and it produced segments, loops back to stream another round from the updated history; otherwise stops.
|
|
57
|
-
5. Touches the chat (bumps `updatedAt`) and returns `{ complete: true }`.
|
|
58
|
-
|
|
59
|
-
Steps 4.ii–4.v use the `askUIEventDocAiAppendStreamChunk`, `askUIEventDocAiAppendChatMessage`, and `askUIEventDocAiClearStream` UI actions — the client renders the reply from those dispatches while this request is still in flight, then reconciles to the finalized message. Because rounds can repeat, the UI may see more than one finalized assistant message appended for what is presented as a single turn.
|
|
60
|
-
|
|
61
|
-
## The chat model
|
|
62
|
-
|
|
63
|
-
Stream parts are **transport-only** — the raw `AiStreamPart`s dispatched live never persist. Only the folded `EventDocAiMessageSegment[]` are written to the chat's history file. A chat's messages are therefore always in the durable segment form (`text`, `reasoning`, `file`, `tool-use`), while the "typing" experience is a separate stream of parts the UI buffers and then discards once the finalized message arrives.
|
|
43
|
+
`EventDocAiChatSendResult | ServiceRequestDeferred` — see [askEventDocAiStreamTurn](./ask-event-doc-ai-stream-turn.md#returns). The reply content is **not** in the return value; it is delivered to the UI during the turn via state dispatches.
|
|
64
44
|
|
|
65
45
|
## Related
|
|
66
46
|
|
|
67
|
-
- [
|
|
47
|
+
- [askEventDocAiStreamTurn](./ask-event-doc-ai-stream-turn.md) — the streaming and handoff this delegates to.
|
|
48
|
+
- [askEventDocAiProcessContinue](./ask-event-doc-ai-process-continue.md) — the resumed-turn counterpart.
|
|
68
49
|
- [askEventDocAiAttachmentsValidate](./ask-event-doc-ai-attachments-validate.md) — the attachment guard.
|
|
69
50
|
- [askEventDocAiChatHistoryLoad / Save](./ask-event-doc-ai-chat-history-load.md) — the history read/write.
|
|
70
51
|
- [askEventDocAiChatTouch](./ask-event-doc-ai-chat-list.md) — bumps the chat afterwards.
|
package/template/docusaurus/docs/actions/features/event-doc-ai/ask-event-doc-ai-stream-turn.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: askEventDocAiStreamTurn
|
|
3
|
+
description: Stream one model reply for a saved chat history, fold it into durable segments, and hand off to a continuation before the runtime deadline.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# askEventDocAiStreamTurn
|
|
7
|
+
|
|
8
|
+
The shared core of a chat turn. Given a history that is already saved, it streams the assistant's reply through [askAiPromptStream](../../core/ai/ask-ai-prompt-stream.md), dispatching each stream part to the browser live, then folds the completed reply into durable segments, saves it, and dispatches it as the finalized message. Both [askEventDocAiProcessSend](./ask-event-doc-ai-process-send.md) (a new turn) and [askEventDocAiProcessContinue](./ask-event-doc-ai-process-continue.md) (a resumed one) end in this story.
|
|
9
|
+
|
|
10
|
+
Its other job is staying inside the execution's time limit. It reads [askGetRuntimeRemainingTime](../../core/system/ask-get-runtime-remaining-time.md), keeps back a fixed headroom, and gives the model the rest as `maxDurationMs`. When there is no budget to start, or the budget cut the model off mid-work, it hands the turn to the collection's continuation service function (async) and returns `SERVICE_REQUEST_DEFERRED` so no reply goes out from this execution.
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
import { askEventDocAiStreamTurn } from 'quidproquo-features';
|
|
14
|
+
|
|
15
|
+
// A resumed turn: the saved history already ends on the partial assistant reply.
|
|
16
|
+
export function* askResume(docId: string, chatId: string, history: EventDocAiChatMessage[]) {
|
|
17
|
+
return yield* askEventDocAiStreamTurn(docId, chatId, history, true);
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Signature
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
function* askEventDocAiStreamTurn(
|
|
25
|
+
docId: string,
|
|
26
|
+
chatId: string,
|
|
27
|
+
history: EventDocAiChatMessage[],
|
|
28
|
+
options: { isContinuation: boolean; lengthResumes: number },
|
|
29
|
+
): AskResponse<EventDocAiChatSendResult | ServiceRequestDeferred>;
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Parameters
|
|
33
|
+
|
|
34
|
+
| Parameter | Type | Description |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| `docId` | `string` | The trusted document the chat is scoped to (from session context). |
|
|
37
|
+
| `chatId` | `string` | The chat being replied to. |
|
|
38
|
+
| `history` | `EventDocAiChatMessage[]` | The full saved history to prompt with. Must already be persisted; this story only appends the reply. |
|
|
39
|
+
| `options.isContinuation` | `boolean` | `true` when resuming. Appends a transport-only nudge as the final user message, because Anthropic rejects a conversation ending on an assistant turn when extended thinking is on. Never saved. |
|
|
40
|
+
| `options.lengthResumes` | `number` | How many consecutive resumes the output token cap has already caused. `0` for a new turn; each `length` handoff passes it on incremented, and the turn stops resuming after three. |
|
|
41
|
+
|
|
42
|
+
## Returns
|
|
43
|
+
|
|
44
|
+
- `{ complete: true }` when the model finished its answer.
|
|
45
|
+
- `{ complete: false }` when a client-side tool call is pending (a tool with no executor). The call is saved in the history; the client renders it and the user's answer arrives as the next chat message.
|
|
46
|
+
- `{ complete: false }` also when the turn was cut off by the time budget or output cap but not resumed (an empty reply, or the length-resume limit).
|
|
47
|
+
- `SERVICE_REQUEST_DEFERRED` when the turn was handed to the continuation. The reply will come from that execution on the same websocket correlation.
|
|
48
|
+
|
|
49
|
+
## What it does
|
|
50
|
+
|
|
51
|
+
1. Reads the remaining runtime and subtracts the headroom. If nothing is left, hands off immediately.
|
|
52
|
+
2. Resolves the AI name, model, reasoning budget, and system prompt (generator inline function first, else the static prompt, else a default; never persisted).
|
|
53
|
+
3. Converts the history to model messages. File segments become drive-referenced file parts the action processor resolves at prompt time, under the collection's storage scope. Tools do **not** receive `docId` from the model; executors inherit the session context and read the trusted id there.
|
|
54
|
+
4. Streams with the time budget as `maxDurationMs`, dispatching each part to the UI (`askUIEventDocAiAppendStreamChunk`) as it arrives, except `tool-input-delta` parts. Those are the argument JSON of a tool call streamed in fragments, often hundreds for one call; the UI only needs `tool-input-start` (show "calling X") and `tool-call` (the full input), so the fragments are collected for the saved message but never sent.
|
|
55
|
+
5. Folds the parts into segments. If any were produced, saves the assistant message and dispatches it as the finalized message (`askUIEventDocAiAppendChatMessage`).
|
|
56
|
+
6. Clears the UI's live-stream buffer and touches the chat (bumps `updatedAt`).
|
|
57
|
+
7. Returns `{ complete: false }` on a pending client tool. Otherwise, if the reply made progress and the finish reason was `toolCalls` (the time budget tripped) or `length` (the output token cap tripped, and fewer than three such resumes have happened), hands off. Otherwise returns `{ complete: true }`, or `{ complete: false }` if the turn was cut off and not resumed.
|
|
58
|
+
|
|
59
|
+
An empty reply that was cut off is not resumed: it would just be cut off again.
|
|
60
|
+
|
|
61
|
+
## Related
|
|
62
|
+
|
|
63
|
+
- [askEventDocAiProcessSend](./ask-event-doc-ai-process-send.md) / [askEventDocAiProcessContinue](./ask-event-doc-ai-process-continue.md) — the two entry points.
|
|
64
|
+
- [askGetRuntimeRemainingTime](../../core/system/ask-get-runtime-remaining-time.md) — the deadline this budgets against.
|
|
65
|
+
- [askAiPromptStream](../../core/ai/ask-ai-prompt-stream.md) — the streamed prompt; [askStreamMap](../../core/stream/ask-stream-map.md) consumes it.
|
|
66
|
+
- [askServiceRequest](../web-socket-queue/ask-service-request.md#deferring-the-reply) — how a deferred reply reaches the caller.
|
|
67
|
+
- [defineEventDocAi](../../../config/features/event-doc-ai.md) — registers the continuation service function this hands off to.
|
|
@@ -19,7 +19,8 @@ type EventDocAiState = {
|
|
|
19
19
|
activeChatId: Nullable<string>;
|
|
20
20
|
|
|
21
21
|
chatMessages: EventDocAiChatMessage[];
|
|
22
|
-
|
|
22
|
+
streamSegments: EventDocAiMessageSegment[];
|
|
23
|
+
isStreaming: boolean;
|
|
23
24
|
|
|
24
25
|
isLoadingChats: boolean;
|
|
25
26
|
isLoadingHistory: boolean;
|
package/template/docusaurus/docs/actions/features/event-doc-ai/streaming-and-status-state.md
CHANGED
|
@@ -5,11 +5,11 @@ description: UI state setters for the in-flight assistant stream, the sending fl
|
|
|
5
5
|
|
|
6
6
|
# Event Doc AI streaming & status state
|
|
7
7
|
|
|
8
|
-
These `ask`-generators are **client-side UI state setters** for the Event Doc AI chat feature. They run inside the browser SPA and mutate the in-memory `EventDocAiState` so the chat UI re-renders. This page covers the setters that drive the **live assistant reply** (`
|
|
8
|
+
These `ask`-generators are **client-side UI state setters** for the Event Doc AI chat feature. They run inside the browser SPA and mutate the in-memory `EventDocAiState` so the chat UI re-renders. This page covers the setters that drive the **live assistant reply** (`streamSegments`), the **sending** flag, and the surfaced **error** message.
|
|
9
9
|
|
|
10
10
|
Each setter dispatches a typed **effect** through the core State action processors: internally it calls `askStateDispatchEffect` → `askStateDispatch`, which the SPA's state runtime folds into `EventDocAiState` via `eventDocAiReducer` (a `buildEffectReducer` over `EventDocAiEffect`). In a browser SPA the State domain is wired through the client state store (see `defineStateDispatchOverWebsockets` / `askStateDispatch` in `quidproquo-core`), so a `yield*` here is a synchronous, local state update. Every setter returns `AskResponse<void>`.
|
|
11
11
|
|
|
12
|
-
`
|
|
12
|
+
`streamSegments` is the reply currently in flight, already in the durable segment format. Each `AiStreamPart` arriving over the socket is folded into the last segment as it lands (`foldStreamPart`): text and reasoning deltas extend the current segment, a `ToolInputStart` adds a tool entry with an empty input, and `ToolCall` / `ToolResult` fill in that entry's input and output. Tool argument deltas are not sent to the browser at all, so a large tool input costs the UI one update at the start and one at the end. `isStreaming` is true from the first part until the stream is cleared, so the UI can show activity before any renderable segment exists. When the reply finishes, the backend dispatches the finalized `EventDocAiChatMessage` (via [`askUIEventDocAiAppendChatMessage`](./active-chat-state.md)) and clears the buffer.
|
|
13
13
|
|
|
14
14
|
## State shape
|
|
15
15
|
|
|
@@ -19,7 +19,8 @@ type EventDocAiState = {
|
|
|
19
19
|
activeChatId: Nullable<string>;
|
|
20
20
|
|
|
21
21
|
chatMessages: EventDocAiChatMessage[];
|
|
22
|
-
|
|
22
|
+
streamSegments: EventDocAiMessageSegment[];
|
|
23
|
+
isStreaming: boolean;
|
|
23
24
|
|
|
24
25
|
isLoadingChats: boolean;
|
|
25
26
|
isLoadingHistory: boolean;
|
|
@@ -37,7 +38,7 @@ type EventDocAiState = {
|
|
|
37
38
|
Appends one streaming chunk to the in-flight assistant reply. Called for each part received while a reply streams in.
|
|
38
39
|
|
|
39
40
|
- **Effect:** `EventDocAiEffect.AppendStreamChunk`
|
|
40
|
-
- **State change:**
|
|
41
|
+
- **State change:** folds `part` into `streamSegments` and sets `isStreaming` to `true`.
|
|
41
42
|
|
|
42
43
|
```typescript
|
|
43
44
|
import { askUIEventDocAiAppendStreamChunk } from 'quidproquo-features';
|
|
@@ -58,7 +59,7 @@ function* askUIEventDocAiAppendStreamChunk(
|
|
|
58
59
|
|
|
59
60
|
| Parameter | Type | Description |
|
|
60
61
|
| --- | --- | --- |
|
|
61
|
-
| `part` | `AiStreamPart` | A single streaming chunk of the in-flight reply,
|
|
62
|
+
| `part` | `AiStreamPart` | A single streaming chunk of the in-flight reply, folded into `streamSegments`. |
|
|
62
63
|
|
|
63
64
|
---
|
|
64
65
|
|
|
@@ -67,7 +68,7 @@ function* askUIEventDocAiAppendStreamChunk(
|
|
|
67
68
|
Clears the in-flight stream buffer — call this once the reply has been finalized into a chat message, or to discard a stream on error/cancel.
|
|
68
69
|
|
|
69
70
|
- **Effect:** `EventDocAiEffect.ClearStream`
|
|
70
|
-
- **State change:** resets `
|
|
71
|
+
- **State change:** resets `streamSegments` to `[]` and `isStreaming` to `false`. Takes no arguments.
|
|
71
72
|
|
|
72
73
|
```typescript
|
|
73
74
|
import {
|
|
@@ -63,6 +63,21 @@ function* askServiceRequest<TPayload, TResponse>(
|
|
|
63
63
|
|
|
64
64
|
`TResponse` — the typed response returned by the target service's handler for that method.
|
|
65
65
|
|
|
66
|
+
## Deferring the reply
|
|
67
|
+
|
|
68
|
+
A handler wrapped by `serviceRequest` normally has its return value sent back as the correlated response. Returning `SERVICE_REQUEST_DEFERRED` instead sends nothing: the handler is promising that a later execution carrying the same session (an async [service function](../../webserver/service-function/ask-service-function-execute.md), say) will answer through `askServiceRequestRespond`. The browser keeps the correlation open, so intermediate state dispatches from those later executions still reach the caller. The eventDocAi chat uses this to resume a turn on a fresh Lambda before the current one times out.
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
import { askServiceRequestRespond, SERVICE_REQUEST_DEFERRED } from 'quidproquo-features';
|
|
72
|
+
|
|
73
|
+
// In the first handler: hand off and defer.
|
|
74
|
+
yield* askServiceFunctionExecute(serviceName, 'continueWork', { jobId }, true);
|
|
75
|
+
return SERVICE_REQUEST_DEFERRED;
|
|
76
|
+
|
|
77
|
+
// In the continuation: answer on the original correlation.
|
|
78
|
+
yield* askServiceRequestRespond({ success: true, result });
|
|
79
|
+
```
|
|
80
|
+
|
|
66
81
|
## Related
|
|
67
82
|
|
|
68
83
|
- [askServiceFunctionExecute](../../webserver/service-function/ask-service-function-execute.md) — direct Lambda-invoke of a named service function.
|
|
@@ -41,6 +41,7 @@ The name of the drive. This is the name you pass as the `drive` argument to ever
|
|
|
41
41
|
| `onEvent` | `StorageDriveEvents` | – | Story functions to run when files are created or deleted in the drive. See [File events](#file-events-onevent). |
|
|
42
42
|
| `lifecycleRules` | `StorageDriveLifecycleRule[]` | – | Rules that transition files to cheaper [storage tiers](#storagedrivetier) or delete them after a period. See [Lifecycle rules](#lifecycle-rules). |
|
|
43
43
|
| `encryption` | `boolean` | `false` | Enables customer-managed KMS encryption for the drive (the KMS key comes from the service's AWS config). When `false`, provider-managed encryption still applies (SSE-S3 on AWS) — this flag only controls the KMS upgrade. |
|
|
44
|
+
| `lockedDown` | `boolean` | `false` | Denies object reads to every principal except the owning service's runtime role (listing and bucket management are unaffected). On AWS, the service stack must pass its `serviceRole` to `QpqCoreStorageDriveConstruct`; synth throws if a `lockedDown` drive has no `serviceRole`. |
|
|
44
45
|
| `owner` | `CrossModuleOwner<'storageDriveName'>` | – | Declares that this drive is owned by **another** module/service. Use this to read/write a drive deployed elsewhere: the deploy grants this service IAM access to the foreign drive instead of creating a new bucket. `{ module, application, feature, environment, storageDriveName }` — all optional; unset parts default to the current service. |
|
|
45
46
|
|
|
46
47
|
## File events (`onEvent`)
|
|
@@ -11,11 +11,12 @@ Declares an **AI chat feature** attached to an [eventDoc](./event-doc.md) collec
|
|
|
11
11
|
|
|
12
12
|
- provisions a **storage drive** for chat histories (one JSON file per chat) and a **key-value store** listing each document's chats,
|
|
13
13
|
- registers an **AI** (model + tools) the chat turns prompt through, and
|
|
14
|
-
- subscribes a dedicated **queue** of four websocket service-request handlers (`onChatCreate`, `onChatList`, `onChatHistory`, `onChatSend`) that ship inside quidproquo-features
|
|
14
|
+
- subscribes a dedicated **queue** of four websocket service-request handlers (`onChatCreate`, `onChatList`, `onChatHistory`, `onChatSend`) that ship inside quidproquo-features, and
|
|
15
|
+
- registers a **continuation service function** (`<storeName>AiChatContinue`) a chat turn hands itself to when the runtime deadline is near, so a long agentic turn spans as many executions as it needs. Each hop runs on the requesting session, streams to the same websocket correlation, and the last one sends the reply.
|
|
15
16
|
|
|
16
17
|
The handlers and any tool executors read the wiring from per-processor globals, exactly like `defineEventDocRoutes`' controllers — you do not write the backend, only configure it.
|
|
17
18
|
|
|
18
|
-
- **On AWS:** deploys the union of the
|
|
19
|
+
- **On AWS:** deploys the union of the five config settings it returns — a DynamoDB table ([defineKeyValueStore](../core/key-value-store.md), partition `docId` / sort `chatId`) for the chat list, an S3 bucket ([defineStorageDrive](../core/storage-drive.md)) for chat-history JSON, the AI registration ([defineAi](../core/ai.md), granting Bedrock model access and registering the `tools`), and an SQS queue ([defineQueue](../core/queue.md)) subscribed to `eventBusName` whose processors are the four chat websocket handlers, plus a Lambda ([defineServiceFunction](../webserver/service-function.md)) for the continuation. Names are derived from `storeName`, so the same config deploys per environment without collisions.
|
|
19
20
|
|
|
20
21
|
```typescript
|
|
21
22
|
import { defineEventDocAi } from 'quidproquo-features';
|
|
@@ -60,6 +61,7 @@ All options are a single `EventDocAiOptions` object.
|
|
|
60
61
|
| `systemPromptGenerator` | `string` | – | Name of a `defineInlineFunction` invoked on **every** turn to build the system prompt. It receives an `EventDocAiSystemPromptInput` (`{ docId }`, the trusted document id) and returns a string — so the prompt can carry live document state. A non-empty result overrides `systemPrompt`; an empty result falls back to it. |
|
|
61
62
|
| `tools` | `AiToolDefinition[]` | `[]` | Tools the model may call, registered on the AI. Executors are `defineInlineFunction` names supplied by the caller. Tool runtimes inherit the chat's session context, so they read the trusted `docId` from context rather than trusting the model to pass it. |
|
|
62
63
|
| `reasoningBudgetTokens` | `number` | `4096` | Extended-thinking token budget. Pass `0` to disable reasoning entirely. Reasoning streams to the chat as `reasoning` segments so the user sees progress instead of a silent wait. |
|
|
64
|
+
| `maxOutputTokens` | `number` | `65536` | Output token cap per model call, defaulting to the Claude Sonnet ceiling on Bedrock (a model with a lower ceiling rejects the request, so lower it for those). The provider default (8192) is too small for a reasoning block plus a large tool input; a call that hits it is cut off mid-JSON. A turn cut off this way is resumed up to three times before it gives up. |
|
|
63
65
|
|
|
64
66
|
## The chat model
|
|
65
67
|
|
|
@@ -25,7 +25,7 @@ export default [
|
|
|
25
25
|
|
|
26
26
|
## Registering a collection's functions
|
|
27
27
|
|
|
28
|
-
`functions` is an `EventDocFunctions` object: `{ storeName, type, foldSnapshotViews, collectReferences, render? }`. The object `createEventDocDefinition` returns — given `storeName`/`type` in its config — satisfies this shape directly, so a collection with no service-only render can register its definition verbatim. A collection that needs a render step only service code can perform (resolving linked docs, reading blob-drive assets) layers it on with `extendEventDocFunctions(definition, { render })`, which returns a new object and never mutates the definition itself.
|
|
28
|
+
`functions` is an `EventDocFunctions` object: `{ storeName, type, foldSnapshotViews, foldDocumentState, collectReferences, collectReferencesFromState, validateEvent, render? }`. The object `createEventDocDefinition` returns — given `storeName`/`type` in its config — satisfies this shape directly, so a collection with no service-only render can register its definition verbatim. A collection that needs a render step only service code can perform (resolving linked docs, reading blob-drive assets) layers it on with `extendEventDocFunctions(definition, { render })`, which returns a new object and never mutates the definition itself.
|
|
29
29
|
|
|
30
30
|
`runtime` is a [`QpqFunctionRuntime`](../core/dynamic-functions.md#runtime--qpqfunctionruntime-required) path to that SAME export — the dynamic-functions pattern: identity is read off the object here at config time, behaviour is loaded from the path by the processors at request time. Both must point at the exact object being registered, or the registration and the runtime will disagree about what the collection can do.
|
|
31
31
|
|
|
@@ -59,6 +59,7 @@ The collection's callable surface, read for its identity (`storeName`, `type`) a
|
|
|
59
59
|
| `foldDocumentState` | `(events, seedState?) => unknown` | yes | The document view at one point, LATEST-shaped, resumable from a stored snapshot's era-pinned document state. The read side's fold: render, references, as-of reads, and the append hooks' state derivation all go through it. |
|
|
60
60
|
| `collectReferences` | `(events) => EventDocLink[]` | yes | The `EventDocLink`s this doc's whole HISTORY depends on; `[]` for a leaf doc type. Invoked by the transfer manifest walk (it exports the whole history). |
|
|
61
61
|
| `collectReferencesFromState` | `(state) => EventDocLink[]` | yes | The `EventDocLink`s the CURRENT state depends on; `[]` for a leaf doc type. Invoked by the references route against a snapshot-seeded folded state. |
|
|
62
|
+
| `validateEvent` | `(event, state) => Nullable<string> \| AskResponse<Nullable<string>>` | yes | The append pre-write gate: checked against the state the event will land on, before the write (see [askEventDocEventAppend](../../actions/features/event-doc/ask-event-doc-event-append.md)). Return a rejection reason string to refuse the append, `null` to allow it. A collection with no domain rules can pass `() => null`. |
|
|
62
63
|
| `render` | `(input: EventDocRenderInput) => EventDocRenderResult \| AskResponse<EventDocRenderResult>` | no | Render the resolved, already-folded document state (`input.state`, resolved snapshot-seeded by the route). Omit and `GET {basePath}/{id}/render` 404s as "no renderer configured". Plain function or story — the dynamic-functions processor runs either. |
|
|
63
64
|
|
|
64
65
|
### `runtime` — `QpqFunctionRuntime` (required)
|