@lotics/app-sdk 0.55.1 → 0.55.2
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/dist/src/agent_stream.d.ts +8 -0
- package/dist/src/agent_stream.js +20 -4
- package/dist/src/hooks.d.ts +8 -0
- package/dist/src/hooks.js +47 -8
- package/dist/src/rpc.d.ts +2 -1
- package/docs/ai.md +11 -4
- package/package.json +2 -5
|
@@ -45,6 +45,14 @@ export interface SettledAgentRun {
|
|
|
45
45
|
status: string;
|
|
46
46
|
output?: unknown;
|
|
47
47
|
error_message?: string | null;
|
|
48
|
+
/** Present on a polled `awaiting_input` row: the pending ask derived from the
|
|
49
|
+
* server-side transcript, so a client that lost the stream (or reloaded) can
|
|
50
|
+
* rebuild the question without ever having received the part. */
|
|
51
|
+
pending_interactive?: {
|
|
52
|
+
tool_call_id: string;
|
|
53
|
+
tool_name: string;
|
|
54
|
+
input: unknown;
|
|
55
|
+
} | null;
|
|
48
56
|
}
|
|
49
57
|
/**
|
|
50
58
|
* Fold a POLLED settled run row into the stream-accumulated state — the shared
|
package/dist/src/agent_stream.js
CHANGED
|
@@ -41,10 +41,26 @@ export function adoptSettledRun(state, settled) {
|
|
|
41
41
|
const structured = settled.output !== null && typeof settled.output === "object" ? settled.output : undefined;
|
|
42
42
|
return { ...state, status: "completed", output: structured ?? state.output };
|
|
43
43
|
}
|
|
44
|
-
// A PARKED row is a live, resumable state —
|
|
45
|
-
//
|
|
44
|
+
// A PARKED row is a live, resumable state — never coerce it into an error.
|
|
45
|
+
// The invariant the hook layer relies on: `awaiting_input` always yields an
|
|
46
|
+
// answerable pending part. When the stream died before the ask part arrived,
|
|
47
|
+
// rebuild it from the row's derived `pending_interactive`.
|
|
46
48
|
if (settled.status === "awaiting_input") {
|
|
47
|
-
|
|
49
|
+
const next = { ...state, status: "awaiting_input" };
|
|
50
|
+
if (pendingInteractiveCall(next))
|
|
51
|
+
return next;
|
|
52
|
+
const p = settled.pending_interactive;
|
|
53
|
+
const alreadyPresent = p && state.parts.some((part) => part.type === "dynamic-tool" && part.toolCallId === p.tool_call_id);
|
|
54
|
+
if (p && INTERACTIVE_TOOLS.has(p.tool_name) && !alreadyPresent) {
|
|
55
|
+
return {
|
|
56
|
+
...next,
|
|
57
|
+
parts: [
|
|
58
|
+
...state.parts,
|
|
59
|
+
{ type: "dynamic-tool", toolName: p.tool_name, toolCallId: p.tool_call_id, state: "input-available", input: p.input },
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return next;
|
|
48
64
|
}
|
|
49
65
|
return { ...state, status: "error", error: settled.error_message ?? "The run was stopped." };
|
|
50
66
|
}
|
|
@@ -103,7 +119,7 @@ export function buildChoiceOutput(questions, answers) {
|
|
|
103
119
|
if (!answer || value.length === 0) {
|
|
104
120
|
return { type: "skipped", question_index: index, question_number: questionNumber, question_text: question.question };
|
|
105
121
|
}
|
|
106
|
-
const optionIndex = answer.custom ? -1 : question.options.findIndex((o) => o.label ===
|
|
122
|
+
const optionIndex = answer.custom ? -1 : question.options.findIndex((o) => o.label === value);
|
|
107
123
|
if (optionIndex >= 0) {
|
|
108
124
|
const option = question.options[optionIndex];
|
|
109
125
|
return {
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -531,6 +531,14 @@ export interface AgentRunRecord {
|
|
|
531
531
|
error_message: string | null;
|
|
532
532
|
started_at: string;
|
|
533
533
|
completed_at: string | null;
|
|
534
|
+
/** Single-run GET only, while `status` is "awaiting_input": the pending ask
|
|
535
|
+
* derived server-side from the transcript — lets a reconnecting client
|
|
536
|
+
* rebuild the question without the live stream. */
|
|
537
|
+
pending_interactive?: {
|
|
538
|
+
tool_call_id: string;
|
|
539
|
+
tool_name: string;
|
|
540
|
+
input: unknown;
|
|
541
|
+
} | null;
|
|
534
542
|
}
|
|
535
543
|
interface AgentRunsState {
|
|
536
544
|
runs: AgentRunRecord[];
|
package/dist/src/hooks.js
CHANGED
|
@@ -467,12 +467,36 @@ export function useAgentRun(alias) {
|
|
|
467
467
|
* connection, so a truncation polls the row instead of surfacing a network
|
|
468
468
|
* error. A leg that ends `awaiting_input` resolves with `undefined`; the
|
|
469
469
|
* final leg resolves with the structured output.
|
|
470
|
+
*
|
|
471
|
+
* `revertTo` (the continuation leg): the leg's initial state is an OPTIMISTIC
|
|
472
|
+
* commit (the ask settled locally before the server accepted the answer). A
|
|
473
|
+
* request that fails before ANY chunk arrives means the server rejected it and
|
|
474
|
+
* the row never left `awaiting_input` — restore the pre-answer state so the
|
|
475
|
+
* question is answerable again, and REJECT so the app surfaces the error.
|
|
476
|
+
* Without this, a 400/409 would be misread as a mid-run connection drop,
|
|
477
|
+
* poll-adopted, and silently swallowed.
|
|
470
478
|
*/
|
|
471
|
-
const streamLeg = useCallback((start, initial) => {
|
|
479
|
+
const streamLeg = useCallback((start, initial, revertTo) => {
|
|
472
480
|
let acc = initial;
|
|
473
481
|
let buffer = "";
|
|
474
482
|
let aborted = false;
|
|
483
|
+
let received = false;
|
|
475
484
|
safeSetState(acc);
|
|
485
|
+
// Enforce the invariant the wizard relies on: `awaiting_input` always
|
|
486
|
+
// yields an answerable pendingChoice. A polled parked row whose question
|
|
487
|
+
// cannot be rebuilt (no part in the stream, none derivable from the row)
|
|
488
|
+
// must fail loud and retryable, never sit as a silent dead end.
|
|
489
|
+
const adoptGuarded = (state, settled) => {
|
|
490
|
+
const adopted = adoptSettledRun(state, settled);
|
|
491
|
+
if (adopted.status === "awaiting_input" && !pendingInteractiveCall(adopted)) {
|
|
492
|
+
return {
|
|
493
|
+
...adopted,
|
|
494
|
+
status: "error",
|
|
495
|
+
error: "The run is waiting for an answer that could not be recovered. Run it again.",
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
return adopted;
|
|
499
|
+
};
|
|
476
500
|
const handle = start((textChunk) => {
|
|
477
501
|
if (aborted)
|
|
478
502
|
return;
|
|
@@ -481,6 +505,7 @@ export function useAgentRun(alias) {
|
|
|
481
505
|
buffer = rest;
|
|
482
506
|
if (chunks.length === 0)
|
|
483
507
|
return;
|
|
508
|
+
received = true;
|
|
484
509
|
for (const c of chunks)
|
|
485
510
|
acc = reduceAgentChunk(acc, c);
|
|
486
511
|
safeSetState({ ...acc });
|
|
@@ -521,7 +546,7 @@ export function useAgentRun(alias) {
|
|
|
521
546
|
// a fake "completed": a structured consumer reading a completed
|
|
522
547
|
// state with no output would render success around a missing result.
|
|
523
548
|
acc = settled
|
|
524
|
-
?
|
|
549
|
+
? adoptGuarded(acc, settled)
|
|
525
550
|
: {
|
|
526
551
|
...acc,
|
|
527
552
|
status: "error",
|
|
@@ -542,6 +567,14 @@ export function useAgentRun(alias) {
|
|
|
542
567
|
.catch(async (err) => {
|
|
543
568
|
if (aborted)
|
|
544
569
|
return undefined;
|
|
570
|
+
// The continue request itself was rejected — the server never resumed
|
|
571
|
+
// the run (400 invalid answer, 409 raced cancel/expiry, network at
|
|
572
|
+
// connect). Restore the pre-answer parked state and surface the error;
|
|
573
|
+
// this is a request failure, not a stream truncation.
|
|
574
|
+
if (revertTo && !received) {
|
|
575
|
+
safeSetState(revertTo);
|
|
576
|
+
throw err;
|
|
577
|
+
}
|
|
545
578
|
// The stream connection dropped, but the run is decoupled from it and
|
|
546
579
|
// keeps executing server-side. Poll the persisted run to completion and
|
|
547
580
|
// surface its result instead of a network error — work is never lost.
|
|
@@ -551,7 +584,7 @@ export function useAgentRun(alias) {
|
|
|
551
584
|
if (aborted)
|
|
552
585
|
return undefined;
|
|
553
586
|
if (settled) {
|
|
554
|
-
acc =
|
|
587
|
+
acc = adoptGuarded(acc, settled);
|
|
555
588
|
safeSetState(acc);
|
|
556
589
|
}
|
|
557
590
|
// The same fleet-visibility beacon as the clean-end path — a dropped
|
|
@@ -593,23 +626,29 @@ export function useAgentRun(alias) {
|
|
|
593
626
|
});
|
|
594
627
|
inflightRef.current = tracked;
|
|
595
628
|
return tracked;
|
|
596
|
-
}, [alias,
|
|
629
|
+
}, [alias, streamLeg]);
|
|
597
630
|
// The run's pending ask — non-null exactly while it is parked on a question.
|
|
598
631
|
const pendingChoice = state ? pendingInteractiveCall(state) : null;
|
|
599
632
|
const answerChoice = useCallback((answers) => {
|
|
633
|
+
// Join first: a double-submit (the wizard's button pressed twice) must
|
|
634
|
+
// land on the in-flight continuation, not reject on the already-settled
|
|
635
|
+
// pending check below.
|
|
636
|
+
if (inflightRef.current)
|
|
637
|
+
return inflightRef.current;
|
|
600
638
|
const current = stateRef.current;
|
|
601
639
|
const runId = runIdRef.current;
|
|
602
640
|
const pending = current ? pendingInteractiveCall(current) : null;
|
|
603
641
|
if (!current || !runId || !pending) {
|
|
604
642
|
return Promise.reject(new Error("No pending question to answer."));
|
|
605
643
|
}
|
|
606
|
-
if (inflightRef.current)
|
|
607
|
-
return inflightRef.current;
|
|
608
644
|
const output = buildChoiceOutput(pending.questions, answers);
|
|
609
|
-
const inflight = streamLeg((onText) => rpcAgentRunContinue({ run_id: runId, tool_call_id: pending.toolCallId, output
|
|
645
|
+
const inflight = streamLeg((onText) => rpcAgentRunContinue({ run_id: runId, tool_call_id: pending.toolCallId, output }, onText),
|
|
610
646
|
// The answered part settles locally (its output rides the feed's
|
|
611
647
|
// on-demand reveal) and the state re-enters streaming for the leg.
|
|
612
|
-
applyInteractiveAnswer(current, pending.toolCallId, output)
|
|
648
|
+
applyInteractiveAnswer(current, pending.toolCallId, output),
|
|
649
|
+
// On a rejected request the leg restores this pre-answer state, so the
|
|
650
|
+
// wizard reappears and the rejection reaches the app.
|
|
651
|
+
current);
|
|
613
652
|
const tracked = inflight.finally(() => {
|
|
614
653
|
if (inflightRef.current === tracked)
|
|
615
654
|
inflightRef.current = null;
|
package/dist/src/rpc.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AskUserChoiceOutput } from "./agent_stream.js";
|
|
1
2
|
import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
|
|
2
3
|
/**
|
|
3
4
|
* RPC bridge for a custom-code app's data operations.
|
|
@@ -151,7 +152,7 @@ export interface AgentRunContinuePayload {
|
|
|
151
152
|
run_id: string;
|
|
152
153
|
tool_call_id: string;
|
|
153
154
|
/** The `ask_user_choice` output the user assembled (validated server-side). */
|
|
154
|
-
output:
|
|
155
|
+
output: AskUserChoiceOutput;
|
|
155
156
|
}
|
|
156
157
|
/**
|
|
157
158
|
* Continue a PARKED (`awaiting_input`) agent run with the user's answer to its
|
package/docs/ai.md
CHANGED
|
@@ -54,7 +54,7 @@ await recognize.run({ image_file_id: fileId }, { sessionId });
|
|
|
54
54
|
| `abort` | `() => void` | Stop listening **locally only** — the run keeps executing server-side and its result is still persisted. This is the unmount path (the hook calls it automatically on unmount) |
|
|
55
55
|
| `status` | `"idle" \| "streaming" \| "awaiting_input" \| "completed" \| "error"` | Whole-run state. `awaiting_input` = the run is PARKED on a question the agent asked (see the ask-back section below). `abort`/`cancel` reset it to `"idle"` (and clear the partial transcript) |
|
|
56
56
|
| `pendingChoice` | `PendingChoice \| null` | The agent's pending question(s) — non-null exactly while `status` is `awaiting_input`. `questions` maps 1:1 onto `@lotics/ui`'s `ClarifyWizard` (`{question, options: {label, description}[], allow_custom}`) |
|
|
57
|
-
| `answerChoice` | `(answers: {value, custom}[]) => Promise<TOutput \| undefined>` | Answer the pending question(s) and CONTINUE the run — one entry per question, aligned by index (exactly what `ClarifyWizard`'s `onSubmit` yields). Streams the continuation into the same `parts`; resolves like `run`. Rejects when nothing is pending |
|
|
57
|
+
| `answerChoice` | `(answers: {value, custom}[]) => Promise<TOutput \| undefined>` | Answer the pending question(s) and CONTINUE the run — one entry per question, aligned by index (exactly what `ClarifyWizard`'s `onSubmit` yields). Streams the continuation into the same `parts`; resolves like `run`. Rejects when nothing is pending — and when the server refuses the answer, in which case the pending question is restored for a retry |
|
|
58
58
|
| `parts` | `AgentUIPart[]` | The ordered live transcript as **ai-sdk `UIMessage.parts`** — answer prose, thinking, and tool calls, in stream order. The single source of truth for the feed; hand it straight to `@lotics/ui` `AgentRun` |
|
|
59
59
|
| `text` | `string` | The agent's **answer prose** (every `text` part concatenated), accumulating live. Excludes thinking. For a free-text agent this IS the result |
|
|
60
60
|
| `output` | `TOutput \| undefined` | The structured result once the run completes. `undefined` when the run produced none |
|
|
@@ -109,9 +109,16 @@ const run = useAgentRun("importer");
|
|
|
109
109
|
|
|
110
110
|
The ask renders in the feed as a settled tool row once answered (the answer rides its
|
|
111
111
|
on-demand reveal). `run()`'s promise resolves `undefined` when the run parks — the
|
|
112
|
-
continuation's promise (from `answerChoice`) carries the final output.
|
|
113
|
-
run
|
|
114
|
-
|
|
112
|
+
continuation's promise (from `answerChoice`) carries the final output. The question is as
|
|
113
|
+
connection-decoupled as the run: a dropped stream can't lose it — the hook's recovery poll
|
|
114
|
+
rebuilds the pending question from the persisted run, so `awaiting_input` always yields an
|
|
115
|
+
answerable `pendingChoice` (the one unrecoverable corner surfaces a retryable `error`, never
|
|
116
|
+
a silent dead end). If the server refuses an answer (an invalid submission, a raced cancel
|
|
117
|
+
or expiry, a connection failure), `answerChoice` REJECTS and the pending question is
|
|
118
|
+
restored — surface the error and let the user submit again; nothing is half-committed.
|
|
119
|
+
`cancel()` on a parked run settles it `aborted` immediately; an unanswered park expires
|
|
120
|
+
after an hour (`error`, "timed out waiting for an answer"). Multi-ask runs work — each
|
|
121
|
+
answer may park again.
|
|
115
122
|
|
|
116
123
|
### `output` typing and the inner-field caveat
|
|
117
124
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/app-sdk",
|
|
3
|
-
"version": "0.55.
|
|
3
|
+
"version": "0.55.2",
|
|
4
4
|
"description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -26,19 +26,16 @@
|
|
|
26
26
|
"prepublishOnly": "npm run build"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
+
"ai": "^7.0.30",
|
|
29
30
|
"posthog-js": "^1.352.0",
|
|
30
31
|
"swr": "^2.4.1"
|
|
31
32
|
},
|
|
32
33
|
"peerDependencies": {
|
|
33
|
-
"ai": ">=7.0.0",
|
|
34
34
|
"react": "^19.2.0",
|
|
35
35
|
"react-dom": "^19.2.0",
|
|
36
36
|
"react-router-dom": "^7.0.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependenciesMeta": {
|
|
39
|
-
"ai": {
|
|
40
|
-
"optional": true
|
|
41
|
-
},
|
|
42
39
|
"react-router-dom": {
|
|
43
40
|
"optional": true
|
|
44
41
|
}
|