@north-light/sdk 0.3.576 → 0.3.578

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/README.md CHANGED
@@ -116,7 +116,7 @@ console.log(outcome.kind);
116
116
 
117
117
  ## Follow the reply to a message
118
118
 
119
- On a run, `runs.reply(runId, sent)` follows the turn that answers one `runs.message` and ends when that turn completes. `turn.completed.error` reports a refused (`content_refused`) or failed (`model_error`) model call.
119
+ On a run, `runs.reply(runId, sent)` follows the turn that answers one `runs.message` and ends when that turn completes. `turn.completed.error` reports a refused (`content_refused`), failed (`model_error`) or rate-limited (`rate_limited`, with `retry_at`) model call.
120
120
 
121
121
  ```ts
122
122
  const sent = await client.runs.message(runId, 'Hello');
@@ -61,7 +61,7 @@ export interface RunReplyCollectOptions {
61
61
  /**
62
62
  * The reply to one message: the root's turn that delivers it. Consume it once:
63
63
  * iterate it, or call `collect()` (text plus how it ended, with an optional
64
- * `onDelta` while it streams) or `text()`. It ends after `turn.completed` (or `run.settled`) and
64
+ * `onDelta` while it streams) or `text()`. If a later message joins the same turn, text after that message belongs to its reply; this reply still waits for the turn to complete. Messages delivered together share the reply. It ends after `turn.completed` (or `run.settled`) and
65
65
  * closes its event stream; aborting the `signal` rejects with `APIUserAbortError`.
66
66
  */
67
67
  export declare class RunReply implements AsyncIterable<RunReplyEvent> {
@@ -2,7 +2,7 @@ import { APIError } from '../errors.js';
2
2
  /**
3
3
  * The reply to one message: the root's turn that delivers it. Consume it once:
4
4
  * iterate it, or call `collect()` (text plus how it ended, with an optional
5
- * `onDelta` while it streams) or `text()`. It ends after `turn.completed` (or `run.settled`) and
5
+ * `onDelta` while it streams) or `text()`. If a later message joins the same turn, text after that message belongs to its reply; this reply still waits for the turn to complete. Messages delivered together share the reply. It ends after `turn.completed` (or `run.settled`) and
6
6
  * closes its event stream; aborting the `signal` rejects with `APIUserAbortError`.
7
7
  */
8
8
  export class RunReply {
@@ -19,6 +19,8 @@ export class RunReply {
19
19
  this.consumed = true;
20
20
  let stream = this.open(this.sent.sequence_number);
21
21
  let inTurn = false;
22
+ let textEnded = false;
23
+ let startingIds = new Set();
22
24
  try {
23
25
  for (;;) {
24
26
  for await (const event of stream) {
@@ -30,11 +32,15 @@ export class RunReply {
30
32
  }
31
33
  if (!inTurn) {
32
34
  inTurn = event.type === 'node.turn.started' && event.input.message_ids.includes(this.sent.message_id);
35
+ if (inTurn && event.type === 'node.turn.started')
36
+ startingIds = new Set(event.input.message_ids);
33
37
  continue;
34
38
  }
35
- if (event.type === 'node.output_text.delta')
39
+ if (event.type === 'node.turn.started' && event.input.message_ids.some((id) => !startingIds.has(id)))
40
+ textEnded = true;
41
+ if (!textEnded && event.type === 'node.output_text.delta')
36
42
  yield { type: 'text.delta', delta: event.delta, sequence_number: event.sequence_number };
37
- else if (event.type === 'node.output_text.done')
43
+ else if (!textEnded && event.type === 'node.output_text.done')
38
44
  yield { type: 'message.done', text: event.text, stop_reason: event.stop_reason, sequence_number: event.sequence_number };
39
45
  else if (event.type === 'node.turn.completed') {
40
46
  yield {
@@ -155,8 +155,9 @@ export declare class Runs {
155
155
  limit?: number;
156
156
  }, options?: RequestOptions): AsyncGenerator<RunObject, void, undefined>;
157
157
  /** Send a message to a run. `start_turn: true` (the default) answers it in its
158
- * own turn: a running run receives it when its current turn ends, and a
159
- * message sent before the first turn has started queues behind that turn,
158
+ * turn. A running run reads it at its next step unless `steer: false` opts out
159
+ * and waits for the current turn to end. A message sent before the first turn
160
+ * has started queues behind that turn,
160
161
  * because a run's `runs.start` prompt is always its first turn's input.
161
162
  * `start_turn: false` stores it without starting a turn; it is delivered as
162
163
  * context at the start of the next turn, ahead of that turn's input. For a
@@ -166,10 +167,11 @@ export declare class Runs {
166
167
  * The send carries an `Idempotency-Key`: `idempotencyKey` when given, otherwise a UUID generated for this
167
168
  * call and reused on every retry of it. The daemon keeps the key for 24 hours; a repeat with the same key
168
169
  * and the same message returns the first send's result without delivering again, and the same key with a
169
- * different message or `start_turn` throws `ConflictError` `idempotency_conflict`. To make a send safe
170
+ * different message, `start_turn`, or effective `steer` throws `ConflictError` `idempotency_conflict`. To make a send safe
170
171
  * across your own restarts, derive the key from your own record of the send (a batch or row id). */
171
172
  message(runId: string, message: string, options?: RequestOptions & {
172
173
  start_turn?: boolean;
174
+ steer?: boolean;
173
175
  idempotencyKey?: string;
174
176
  }): Promise<SentRunMessage>;
175
177
  /** A keyed POST under one overall deadline. `budgetMs` / `deadlineAt` (fixed here, once) bound every attempt, every
@@ -41,8 +41,9 @@ export class Runs {
41
41
  }
42
42
  }
43
43
  /** Send a message to a run. `start_turn: true` (the default) answers it in its
44
- * own turn: a running run receives it when its current turn ends, and a
45
- * message sent before the first turn has started queues behind that turn,
44
+ * turn. A running run reads it at its next step unless `steer: false` opts out
45
+ * and waits for the current turn to end. A message sent before the first turn
46
+ * has started queues behind that turn,
46
47
  * because a run's `runs.start` prompt is always its first turn's input.
47
48
  * `start_turn: false` stores it without starting a turn; it is delivered as
48
49
  * context at the start of the next turn, ahead of that turn's input. For a
@@ -52,11 +53,11 @@ export class Runs {
52
53
  * The send carries an `Idempotency-Key`: `idempotencyKey` when given, otherwise a UUID generated for this
53
54
  * call and reused on every retry of it. The daemon keeps the key for 24 hours; a repeat with the same key
54
55
  * and the same message returns the first send's result without delivering again, and the same key with a
55
- * different message or `start_turn` throws `ConflictError` `idempotency_conflict`. To make a send safe
56
+ * different message, `start_turn`, or effective `steer` throws `ConflictError` `idempotency_conflict`. To make a send safe
56
57
  * across your own restarts, derive the key from your own record of the send (a batch or row id). */
57
58
  async message(runId, message, options = {}) {
58
- const { idempotencyKey = crypto.randomUUID(), maxRetries = 2, start_turn, ...rest } = options;
59
- const body = { message, ...(start_turn === undefined ? {} : { start_turn }) };
59
+ const { idempotencyKey = crypto.randomUUID(), maxRetries = 2, start_turn, steer, ...rest } = options;
60
+ const body = { message, ...(start_turn === undefined ? {} : { start_turn }), ...(steer === undefined ? {} : { steer }) };
60
61
  // One key per logical send, reused on every retry: the daemon answers a repeat with the first send's
61
62
  // result, so a retry after a lost response cannot deliver the message twice.
62
63
  return this.keyedSend(`${runPath(runId)}/messages`, body, idempotencyKey, maxRetries, rest);
@@ -2,7 +2,7 @@
2
2
  name: northlight
3
3
  description: "Use when building, debugging or shipping an app that signs people in with Northlight, starts runs on their Northlight runtime, or calls Northlight providers (sms, search, integrations, context, device) with @north-light/sdk."
4
4
  metadata:
5
- sdk-version: "0.3.576"
5
+ sdk-version: "0.3.578"
6
6
  ---
7
7
 
8
8
  # Building a Northlight app
@@ -27,7 +27,7 @@ Never text a person first: Comms refuses a send to anyone who has not texted you
27
27
 
28
28
  ## First: check this skill matches the SDK
29
29
 
30
- This skill is for SDK version `0.3.576`. If the project has `node_modules/@north-light/sdk/package.json`, compare its `version` with that number. If they differ, run `npx @north-light/sdk skill` in the project, then read the rewritten `.agents/skills/northlight/SKILL.md` (or `.claude/skills/northlight/SKILL.md`) instead of this copy. It needs no account. A project that has no SDK yet starts with `npx @north-light/sdk@latest init <dir>`.
30
+ This skill is for SDK version `0.3.578`. If the project has `node_modules/@north-light/sdk/package.json`, compare its `version` with that number. If they differ, run `npx @north-light/sdk skill` in the project, then read the rewritten `.agents/skills/northlight/SKILL.md` (or `.claude/skills/northlight/SKILL.md`) instead of this copy. It needs no account. A project that has no SDK yet starts with `npx @north-light/sdk@latest init <dir>`.
31
31
 
32
32
  ## Where things run
33
33
 
@@ -966,9 +966,14 @@ export class FakeCloud {
966
966
  throw invalid('message is required', 'message');
967
967
  if (params['start_turn'] !== undefined && typeof params['start_turn'] !== 'boolean')
968
968
  throw invalid('start_turn must be a boolean', 'start_turn');
969
+ if (params['steer'] !== undefined && typeof params['steer'] !== 'boolean')
970
+ throw invalid('steer must be a boolean', 'steer');
969
971
  const startTurn = params['start_turn'] ?? true;
972
+ if (!startTurn && params['steer'] === true)
973
+ throw invalid('steer requires start_turn', 'steer');
974
+ const steer = startTurn && params['steer'] !== false;
970
975
  const key = this.idempotencyKey(rawKey);
971
- const hash = sha256Hex(JSON.stringify(['run.message', run.id, text, startTurn]));
976
+ const hash = sha256Hex(JSON.stringify(['run.message', run.id, text, startTurn, ...(startTurn && !steer ? ['steer:false'] : [])]));
972
977
  if (key !== null) {
973
978
  const entry = user.idempotent(key);
974
979
  if (entry) {
@@ -986,7 +991,9 @@ export class FakeCloud {
986
991
  const response = { run_id: run.id, message_id: accepted.message_id, sequence_number: accepted.sequence_number };
987
992
  if (key !== null)
988
993
  user.idempotency.set(key, { hash, runId: run.id, status: 202, body: structuredClone(response), createdAt: this.clock.now() });
989
- if (startTurn)
994
+ if (startTurn && steer && run.isTurnOpen())
995
+ run.steer([accepted]);
996
+ else if (startTurn)
990
997
  this.respond(run, 'message', [accepted]);
991
998
  return { status: 202, body: response };
992
999
  }
@@ -134,9 +134,10 @@ export declare class FakeRun {
134
134
  messageIds?: string[];
135
135
  }): void;
136
136
  /** The same turn ending in error: `last_turn_error` set, status idle. */
137
- failTurn({ code, message, messageIds }: {
137
+ failTurn({ code, message, retry_at, messageIds }: {
138
138
  code: TurnErrorDTO['code'];
139
139
  message: string;
140
+ retry_at?: string;
140
141
  messageIds?: string[];
141
142
  }): void;
142
143
  /** Fake-only: status `running` with `activity` overridden until the next appended event. */
@@ -157,6 +158,9 @@ export declare class FakeRun {
157
158
  expireEvents(upTo: number): void;
158
159
  /** Record an accepted message; returns it. */
159
160
  accept(text: string, startTurn: boolean, idempotencyKey: string | null): FakeRunMessage;
161
+ /** Deliver accepted messages into the open root turn at the next scripted step. */
162
+ steer(messages: readonly FakeRunMessage[]): void;
163
+ isTurnOpen(): boolean;
160
164
  /** Clear a non-canceled ending so a start-turn message runs it again (`commitReopenResident`). */
161
165
  reopen(): void;
162
166
  /** The app answered a question. */
@@ -142,11 +142,14 @@ export class FakeRun {
142
142
  });
143
143
  }
144
144
  /** The same turn ending in error: `last_turn_error` set, status idle. */
145
- failTurn({ code, message, messageIds }) {
145
+ failTurn({ code, message, retry_at, messageIds }) {
146
+ if ((code === 'rate_limited') !== (retry_at !== undefined))
147
+ throw new Error('failTurn: retry_at is required with code rate_limited and only allowed there');
148
+ const error = retry_at === undefined ? { code, message } : { code, message, retry_at };
146
149
  this.turn(messageIds, (ids) => {
147
150
  this.append('node.output_text.done', { text: '', stop_reason: 'error', entry_id: entryId() });
148
- this.turnError = { code, message };
149
- return { stop_reason: 'error', ids, error: { code, message } };
151
+ this.turnError = error;
152
+ return { stop_reason: 'error', ids, error };
150
153
  });
151
154
  }
152
155
  /** Fake-only: status `running` with `activity` overridden until the next appended event. */
@@ -249,6 +252,20 @@ export class FakeRun {
249
252
  this.wake();
250
253
  return message;
251
254
  }
255
+ /** Deliver accepted messages into the open root turn at the next scripted step. */
256
+ steer(messages) {
257
+ if (!this.turnOpen())
258
+ throw new Error('no open turn to steer');
259
+ const started = this.log.filter((event) => event.node_id === this.id && event.type === 'node.turn.started').at(-1);
260
+ const ids = [...started.input.message_ids];
261
+ for (const message of messages) {
262
+ if (!ids.includes(message.message_id))
263
+ ids.push(message.message_id);
264
+ this.append('node.turn.started', { input: { text: message.text, message_ids: [...ids] }, entry_id: entryId() });
265
+ message.delivered = true;
266
+ }
267
+ }
268
+ isTurnOpen() { return this.turnOpen(); }
252
269
  /** Clear a non-canceled ending so a start-turn message runs it again (`commitReopenResident`). */
253
270
  reopen() {
254
271
  this.outcomeValue = undefined;
@@ -414,8 +431,9 @@ export class FakeRun {
414
431
  this.answered = false;
415
432
  // The status the run reads once this turn is closed (`run_status` is written as the turn ends).
416
433
  const status = this.messages.some((m) => m.start_turn && !m.delivered) ? 'running' : this.pendingQuestionIds.length ? 'waiting_on_user' : 'idle';
434
+ const steered = this.log.filter((event) => event.node_id === this.id && event.type === 'node.turn.started').at(-1);
417
435
  this.append('node.turn.completed', {
418
- message_ids: ending.ids, run_status: status, stop_reason: ending.stop_reason,
436
+ message_ids: [...new Set([...ending.ids, ...steered.input.message_ids])], run_status: status, stop_reason: ending.stop_reason,
419
437
  ...(ending.error ? { error: ending.error } : {}), entry_id: entryId(),
420
438
  });
421
439
  }
package/dist/version.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** This SDK's release version. Informational only: compatibility is the contract level, not this number. */
2
- export declare const SDK_VERSION = "0.3.576";
2
+ export declare const SDK_VERSION = "0.3.578";
3
3
  /** The release sent in `Crouter-Minimum-Runtime-Version` on every request. It is frozen, so a runtime from before the
4
4
  * contract level existed (which only knows this header) refuses this SDK, while every later runtime accepts it. */
5
5
  export declare const LEGACY_FLOOR = "0.3.423";
package/dist/version.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // SDK_VERSION is written by scripts/release-version.mjs on every release; do not edit by hand.
2
2
  /** This SDK's release version. Informational only: compatibility is the contract level, not this number. */
3
- export const SDK_VERSION = '0.3.576';
3
+ export const SDK_VERSION = '0.3.578';
4
4
  // LEGACY_FLOOR is written once by `scripts/contract.mjs release`, at the first release that carries the contract level
5
5
  // (the `0.0.0` placeholder is replaced there and never again); do not edit by hand.
6
6
  /** The release sent in `Crouter-Minimum-Runtime-Version` on every request. It is frozen, so a runtime from before the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@north-light/sdk",
3
- "version": "0.3.576",
4
- "crouterContract": "1.38",
3
+ "version": "0.3.578",
4
+ "crouterContract": "1.40",
5
5
  "description": "Typed Node and browser client for running crouter agents through the crtrd /v1 API.",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
@@ -44,9 +44,9 @@
44
44
  "prepublishOnly": "node -e \"if (process.env.npm_config_tag !== 'next') { console.error('@north-light/sdk publishes only with --tag next; scripts/promote-sdk.mjs is the only mover of latest.'); process.exit(1); }\""
45
45
  },
46
46
  "dependencies": {
47
- "@north-light/api": "0.3.576",
48
- "@north-light/identity": "0.3.576",
49
- "@north-light/plugin": "0.3.576",
47
+ "@north-light/api": "0.3.578",
48
+ "@north-light/identity": "0.3.578",
49
+ "@north-light/plugin": "0.3.578",
50
50
  "jose": "^6.2.1"
51
51
  },
52
52
  "peerDependencies": {