@lotics/app-sdk 0.60.3 → 0.61.1

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.
@@ -104,8 +104,6 @@ export interface PendingChoice {
104
104
  toolCallId: string;
105
105
  questions: ChoiceQuestion[];
106
106
  }
107
- /** The run's pending `ask_user_choice`, parsed for rendering (a `ClarifyWizard`
108
- * maps 1:1) — non-null exactly while the run is parked awaiting the answer. */
109
107
  export declare function pendingInteractiveCall(state: AgentRunState): PendingChoice | null;
110
108
  /**
111
109
  * Fold the user's picks into the tool's output shape — `answers` aligns to
@@ -66,6 +66,15 @@ export function adoptSettledRun(state, settled) {
66
66
  }
67
67
  /** The run's pending `ask_user_choice`, parsed for rendering (a `ClarifyWizard`
68
68
  * maps 1:1) — non-null exactly while the run is parked awaiting the answer. */
69
+ /** Untrusted model output — a non-JSON string is data, not an exception. */
70
+ function decodeJson(raw) {
71
+ try {
72
+ return JSON.parse(raw);
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
69
78
  export function pendingInteractiveCall(state) {
70
79
  if (state.status !== "awaiting_input")
71
80
  return null;
@@ -81,9 +90,16 @@ export function pendingInteractiveCall(state) {
81
90
  return null;
82
91
  }
83
92
  function parseChoiceQuestions(input) {
84
- if (!input || typeof input !== "object")
93
+ // A model may hand the tool its arguments as a JSON STRING rather than an
94
+ // object. The SERVER accepts either and parks the run, so refusing the string
95
+ // here strands a live `awaiting_input` run: the question exists, the run is
96
+ // answerable, and the UI reports it as unrecoverable. Parse before validating
97
+ // — a string that isn't JSON falls through to the same empty result as any
98
+ // other malformed input, which `adoptGuarded` surfaces loudly and retryably.
99
+ const decoded = typeof input === "string" ? decodeJson(input) : input;
100
+ if (!decoded || typeof decoded !== "object")
85
101
  return [];
86
- const record = input;
102
+ const record = decoded;
87
103
  if (!Array.isArray(record.questions))
88
104
  return [];
89
105
  const questions = [];
package/dist/src/rpc.d.ts CHANGED
@@ -170,6 +170,8 @@ export declare function rpcAgentRunContinue(payload: AgentRunContinuePayload, on
170
170
  * `APP_PUBLIC_SESSION_HEADER`; both sides are pinned by tests.
171
171
  */
172
172
  export declare const APP_PUBLIC_SESSION_HEADER = "x-lotics-app-session";
173
+ /** The run token header — mirrored server-side by `APP_AGENT_RUN_TOKEN_HEADER`. */
174
+ export declare const APP_AGENT_RUN_TOKEN_HEADER = "x-app-agent-run-token";
173
175
  /**
174
176
  * The error message for a non-ok response. A genuine JSON error (a 4xx carrying
175
177
  * a `message`) surfaces verbatim; a non-JSON body (a gateway HTML page), any
package/dist/src/rpc.js CHANGED
@@ -250,6 +250,11 @@ export function rpcAgentRunContinue(payload, onText) {
250
250
  const headers = { "content-type": "application/json" };
251
251
  if (sessionToken)
252
252
  headers[APP_PUBLIC_SESSION_HEADER] = sessionToken;
253
+ // Continuing an anonymous parked run is authorized by the same per-run
254
+ // capability that reads it — there is no member to authorize instead.
255
+ const continueToken = runTokens.get(payload.run_id);
256
+ if (continueToken)
257
+ headers[APP_AGENT_RUN_TOKEN_HEADER] = continueToken;
253
258
  const res = await fetch(`${API_BASE}/v1/apps/${app_id}/agent-runs/${encodeURIComponent(payload.run_id)}/continue`, {
254
259
  method: "POST",
255
260
  headers,
@@ -297,8 +302,12 @@ function agentRunStandalone(payload, onText, onRunId) {
297
302
  throw await streamStartError(res);
298
303
  }
299
304
  const runId = res.headers.get("x-app-agent-run-id");
300
- if (runId)
305
+ if (runId) {
306
+ // Anonymous runs carry their retrieval capability here; a member run does
307
+ // not, and `rememberRunToken` no-ops on the null.
308
+ rememberRunToken(runId, res.headers.get(APP_AGENT_RUN_TOKEN_HEADER));
301
309
  onRunId?.(runId);
310
+ }
302
311
  const reader = res.body.getReader();
303
312
  const decoder = new TextDecoder();
304
313
  try {
@@ -343,6 +352,40 @@ let sessionToken = null;
343
352
  * `APP_PUBLIC_SESSION_HEADER`; both sides are pinned by tests.
344
353
  */
345
354
  export const APP_PUBLIC_SESSION_HEADER = "x-lotics-app-session";
355
+ /**
356
+ * Per-run retrieval credentials for ANONYMOUS agent runs, keyed by run id.
357
+ *
358
+ * A member's run is authorized by their identity, so reading it back needs
359
+ * nothing extra. An anonymous run on a publicly-shared app has no member to key
360
+ * on (`triggered_by_member_id` is null by design), so the server mints a
361
+ * capability token bound to that one run and returns it on the run response.
362
+ * Without it the recovery poll 404s and a completed answer is lost to any
363
+ * dropped connection — routine on mobile for a run measured in tens of seconds.
364
+ *
365
+ * Transport-level, like `sessionToken` above and for the same reason: it is a
366
+ * credential, not app state. Threading it through the hook would put a bearer
367
+ * into the app's typed surface for every app to forward by hand.
368
+ *
369
+ * Only the STANDALONE transport needs this — an embedded app always runs under
370
+ * a member session, so it never produces an anonymous run.
371
+ */
372
+ const runTokens = new Map();
373
+ /** Bound the map so a long-lived page cannot accumulate tokens without limit.
374
+ * Insertion-ordered, so the oldest entry is the first key. */
375
+ const MAX_TRACKED_RUN_TOKENS = 8;
376
+ function rememberRunToken(runId, token) {
377
+ if (!token)
378
+ return;
379
+ runTokens.set(runId, token);
380
+ while (runTokens.size > MAX_TRACKED_RUN_TOKENS) {
381
+ const oldest = runTokens.keys().next();
382
+ if (oldest.done)
383
+ break;
384
+ runTokens.delete(oldest.value);
385
+ }
386
+ }
387
+ /** The run token header — mirrored server-side by `APP_AGENT_RUN_TOKEN_HEADER`. */
388
+ export const APP_AGENT_RUN_TOKEN_HEADER = "x-app-agent-run-token";
346
389
  /**
347
390
  * The cookie the app-host Worker sets after the visitor clears the password
348
391
  * gate. Readable by design — the SDK forwards its value as the session header
@@ -473,6 +516,11 @@ async function apiCall(method, path, body, opts) {
473
516
  if (sessionToken && !opts?.skipAuth) {
474
517
  headers[APP_PUBLIC_SESSION_HEADER] = sessionToken;
475
518
  }
519
+ // The per-run capability for an ANONYMOUS run — the only thing that authorizes
520
+ // reading or cancelling it, since there is no member identity to check.
521
+ if (opts?.runToken) {
522
+ headers[APP_AGENT_RUN_TOKEN_HEADER] = opts.runToken;
523
+ }
476
524
  const controller = new AbortController();
477
525
  let didTimeout = false;
478
526
  const timeoutId = setTimeout(() => {
@@ -716,6 +764,7 @@ async function standaloneAgentRunGet(p) {
716
764
  const { app_id } = await boot();
717
765
  const r = (await apiCall("GET", `/v1/apps/${app_id}/agent-runs/${encodeURIComponent(p.run_id)}`, undefined, {
718
766
  appId: app_id,
767
+ runToken: runTokens.get(p.run_id),
719
768
  }));
720
769
  return { run: r.run };
721
770
  }
@@ -725,6 +774,7 @@ async function standaloneAgentRunCancel(p) {
725
774
  const { app_id } = await boot();
726
775
  await apiCall("POST", `/v1/apps/${app_id}/agent-runs/${encodeURIComponent(p.run_id)}/cancel`, {}, {
727
776
  appId: app_id,
777
+ runToken: runTokens.get(p.run_id),
728
778
  });
729
779
  return { ok: true };
730
780
  }
package/docs/ai.md CHANGED
@@ -21,7 +21,7 @@ A declaration carries:
21
21
  |---|---|
22
22
  | `instructions` | System instructions — the task the agent performs per run |
23
23
  | `tool_names` | The tools the agent may call, resolved against the platform's automation tool registry. The capability boundary for everything EXCEPT workspace data — the run can use nothing else. May be empty — including for an agent that reads documents, since a [`file` input carries its own content](#file-inputs--what-the-agent-can-actually-see) |
24
- | `knowledge_doc_ids` | The knowledge docs the agent may read. Small docs are materialized into the run's system context; a doc too large to inline is read by staging it into a code run, which requires `code_exec` in `tool_names` |
24
+ | `knowledge_doc_ids` | The knowledge docs the agent may read, and the whole set it can reach a doc absent from this list is unreadable even if the agent names its id. Declare `grep_knowledge` / `read_knowledge` in `tool_names` to read them. There is no size limit and nothing is inlined, so a multi-megabyte reference corpus (a full tariff, a regulation set) is a normal declaration. Reach for `code_exec` only to COMPUTE across the corpus — counting, cross-referencing — never merely to read it |
25
25
  | `query_aliases` | The app's own named queries the agent may run via `run_app_query` — its **entire read surface** over records |
26
26
  | `workflow_aliases` | The app's own workflows the agent may invoke via `run_app_workflow` — its **entire write surface** |
27
27
  | `model_id` | Optional chat model pin. Omit (preferred) to follow the platform default chat model, resolved at run time — the agent tracks model generations with no rewrite. Pin only a deliberate, tested choice |
@@ -184,6 +184,8 @@ The run's lifetime is decoupled from the stream: the server drives it to complet
184
184
 
185
185
  Both truncation shapes emit the `app_agent_stream_truncated` analytics event (`kind: "connection_error" | "clean_end"`, with whether the row rescued the result), so edge-cut frequency is visible fleet-wide.
186
186
 
187
+ **Anonymous runs poll with a capability token, handled for you.** On a publicly-shared app a visitor has no member identity, so the server cannot authorize their poll by ownership — `triggered_by_member_id` is null by design. Instead the run response carries a per-run token (`x-app-agent-run-token`); the SDK stores it against that run id and replays it on the poll, on `cancel`, and on `answerChoice`. It is transport-level, like the password-session token — never surfaced to app code, and never issued for a member run (identity already authorizes those). Nothing to wire: an app calls `useAgentRun` the same way on both. The token is bound to ONE run, so it cannot read another visitor's, and it is why a dropped connection on a public app recovers rather than losing the answer.
188
+
187
189
  The poll is bounded at 22 minutes — deliberately PAST the server's 20-minute hard run cap, so a live run always settles before the client gives up. A row still `running` at the deadline means the run's process died mid-flight (e.g. a crash that skipped the server's shutdown drain); the poll surfaces an error and the server's reaper repairs the row. Only when no run id was ever received (the run never started) does the failure reject before any polling.
188
190
 
189
191
  On recovery, `output` adopts the row's output **only when it is an object** (a structured result) — the "`output` is never a stray string" rule holds on every path. A **free-text** run recovered from truncation keeps only the streamed prefix in `text`; the full settled answer is persisted server-side but is not currently client-readable (`useAgentRuns` can't reach it on any transport — see the limitation below), so treat the streamed prefix as terminal for now.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.60.3",
4
- "description": "Runtime SDK for Lotics custom-code apps \u2014 typed hooks, postMessage bridge, mount entry point",
3
+ "version": "0.61.1",
4
+ "description": "Runtime SDK for Lotics custom-code apps typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {