@2kw/ai 8.0.3 → 8.1.0-dev.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.
@@ -9,6 +9,7 @@ import { buildRunEnvelope, EXIT_CODES, MODE_OPTION_HELP, modeItem, parseModeOpti
9
9
  import { continueWithDecisions, decisionsFromAnswers, fetchPendingApprovals, parseToolAnswers, planContinuation, } from "../lib/agent-decide.js";
10
10
  import { PromptAbortedError, promptApprovals, readlineAsk } from "../lib/approval-prompt.js";
11
11
  import { withModelOverride } from "../lib/agent-models.js";
12
+ import { postResponse } from "../lib/response-stream.js";
12
13
  import { readStdinInput } from "./ai.js";
13
14
  export function resolveRunInput(arg, inputFile, readStdin = readStdinInput) {
14
15
  const text = readRunInput(arg, inputFile, readStdin);
@@ -146,7 +147,7 @@ export function makeAgentRunCommand() {
146
147
  const body = {
147
148
  model: `agent/${agentRef}`,
148
149
  input: mode ? [...turn, modeItem(mode)] : text ?? [],
149
- stream: false,
150
+ stream: true,
150
151
  };
151
152
  if (opts.conversation)
152
153
  body.conversation = opts.conversation;
@@ -154,9 +155,17 @@ export function makeAgentRunCommand() {
154
155
  body.previous_response_id = opts.continue;
155
156
  let data;
156
157
  try {
157
- // The generated body type does not model a bare-string input, hence the cast.
158
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
159
- ({ data } = await withSpinner("Running agent...", () => client.POST("/v1/responses", { body })));
158
+ data = await withSpinner("Running agent...", (spinner) => postResponse(client, body, (started) => {
159
+ // Printed at once (#1729), so a run cut off later can still be found.
160
+ if (!started.conversation?.id)
161
+ return;
162
+ // ora's render() writes a frame even off a TTY, where it never spins.
163
+ if (spinner.isSpinning)
164
+ spinner.clear();
165
+ console.error(`Conversation: ${started.conversation.id}`);
166
+ if (spinner.isSpinning)
167
+ spinner.render();
168
+ }));
160
169
  }
161
170
  catch (err) {
162
171
  throw await withAgentSuggestions(client, agentRef, err);
@@ -5,6 +5,7 @@ import { chatUrlFor, isJsonOutput, resolveConfig } from "../lib/config.js";
5
5
  import { CliUsageError } from "../lib/errors.js";
6
6
  import { withModelOverride } from "../lib/agent-models.js";
7
7
  import { formatList, withSpinner } from "../lib/output.js";
8
+ import { postResponse } from "../lib/response-stream.js";
8
9
  import { buildRunEnvelope, extractResponseText, printRunText, } from "../lib/agent-run.js";
9
10
  import chalk from "chalk";
10
11
  export { extractResponseText };
@@ -125,7 +126,19 @@ export function makeAiCommand() {
125
126
  if (opts.conversation)
126
127
  body.conversation = opts.conversation;
127
128
  const client = getClient(command);
128
- const { data } = await withSpinner("Generating response...", () => client.POST("/v1/responses", { body }));
129
+ // An agent turn is streamed (#1732): a proxy cuts a request that answers nothing for 100 s.
130
+ const data = await withSpinner("Generating response...", async (spinner) => opts.agent
131
+ ? postResponse(client, body, (started) => {
132
+ // Printed at once, as `agents run` does, so a turn cut off later can still be found.
133
+ if (!started.conversation?.id)
134
+ return;
135
+ if (spinner.isSpinning)
136
+ spinner.clear();
137
+ console.error(`Conversation: ${started.conversation.id}`);
138
+ if (spinner.isSpinning)
139
+ spinner.render();
140
+ })
141
+ : (await client.POST("/v1/responses", { body })).data);
129
142
  printResponseResult(data, command, opts.agent);
130
143
  });
131
144
  });
@@ -7,7 +7,8 @@ export function makeResponsesCommand() {
7
7
  cmd
8
8
  .command("get")
9
9
  .description("Read a background response as it stands now: queued, in_progress, requires_action, completed, " +
10
- "incomplete, failed or cancelled. Only background responses can be read by id.")
10
+ "incomplete, failed or cancelled. Only background responses can be read by id: the result of " +
11
+ "`agents run` is read with `conversations items list <conversationId>`.")
11
12
  .argument("<responseId>", "Response ID")
12
13
  .action(async (responseId, _opts, command) => {
13
14
  await runAction(command, async () => {
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { modeItem } from "./agent-run.js";
3
3
  import { CliUsageError } from "./errors.js";
4
4
  import { paginationParams } from "./pagination.js";
5
+ import { postResponse } from "./response-stream.js";
5
6
  const defaultIo = {
6
7
  readFile: (path) => readFileSync(path, "utf-8"),
7
8
  // Not readStdinInput: that one trims, and a tool result is sent as read (P5).
@@ -215,12 +216,10 @@ export async function continueWithDecisions(client, agentId, responseId, decisio
215
216
  const body = {
216
217
  model: `agent/${agentId}${label ? `@${label}` : ""}${model ? `#${model}` : ""}`,
217
218
  previous_response_id: responseId,
218
- stream: false,
219
+ stream: true,
219
220
  input: mode ? [...items, modeItem(mode)] : items,
220
221
  };
221
- // The generated body type does not model backbone: extension items, hence the cast.
222
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
223
- const { data } = await client.POST("/v1/responses", { body });
224
- return data;
222
+ // Streamed for the reason a run is (#1729): the rest of the turn can outlast a proxy's timeout.
223
+ return postResponse(client, body);
225
224
  }
226
225
  //# sourceMappingURL=agent-decide.js.map
@@ -1,3 +1,4 @@
1
+ import { type Ora } from "ora";
1
2
  import type { Command } from "commander";
2
3
  type AnyRecord = Record<string, any>;
3
4
  /**
@@ -24,6 +25,6 @@ export declare function formatSuccess(message: string, command: Command): void;
24
25
  /**
25
26
  * Run an async action with a spinner.
26
27
  */
27
- export declare function withSpinner<T>(text: string, action: () => Promise<T>): Promise<T>;
28
+ export declare function withSpinner<T>(text: string, action: (spinner: Ora) => Promise<T>): Promise<T>;
28
29
  export {};
29
30
  //# sourceMappingURL=output.d.ts.map
@@ -88,7 +88,7 @@ export function formatSuccess(message, command) {
88
88
  export async function withSpinner(text, action) {
89
89
  const spinner = ora(text).start();
90
90
  try {
91
- const result = await action();
91
+ const result = await action(spinner);
92
92
  spinner.succeed();
93
93
  return result;
94
94
  }
@@ -0,0 +1,14 @@
1
+ import type { ApiClient } from "./agent-lookup.js";
2
+ import type { ResponsesResult } from "./agent-run.js";
3
+ /**
4
+ * POSTs an agent turn to `/v1/responses` and returns its terminal response, as `stream: false` would (#1729).
5
+ * The turn is streamed: a proxy in front of the API cuts a request that answers nothing for 100 s (HTTP 524),
6
+ * and a stream's events and keepalives keep it open however long the run takes. A model that cannot stream
7
+ * is refused before any work (`stream_not_supported`), so that turn is sent again without a stream.
8
+ *
9
+ * `onStart` gets the response as `response.created` names it, before the run works. Once the conversation is
10
+ * known, a cut stream or Ctrl+C names it and the command that reads the run's result: a synchronous
11
+ * response cannot be read by id afterwards (`GET /v1/responses/{id}` reads background responses only).
12
+ */
13
+ export declare function postResponse(client: ApiClient, body: Record<string, unknown>, onStart?: (started: ResponsesResult) => void): Promise<ResponsesResult>;
14
+ //# sourceMappingURL=response-stream.d.ts.map
@@ -0,0 +1,120 @@
1
+ import { BackboneApiError } from "./errors.js";
2
+ /**
3
+ * POSTs an agent turn to `/v1/responses` and returns its terminal response, as `stream: false` would (#1729).
4
+ * The turn is streamed: a proxy in front of the API cuts a request that answers nothing for 100 s (HTTP 524),
5
+ * and a stream's events and keepalives keep it open however long the run takes. A model that cannot stream
6
+ * is refused before any work (`stream_not_supported`), so that turn is sent again without a stream.
7
+ *
8
+ * `onStart` gets the response as `response.created` names it, before the run works. Once the conversation is
9
+ * known, a cut stream or Ctrl+C names it and the command that reads the run's result: a synchronous
10
+ * response cannot be read by id afterwards (`GET /v1/responses/{id}` reads background responses only).
11
+ */
12
+ export async function postResponse(client, body, onStart) {
13
+ let response;
14
+ try {
15
+ // The generated body type does not model a bare-string input or backbone: items, hence the cast.
16
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
17
+ ({ response } = await client.POST("/v1/responses", { body: { ...body, stream: true }, parseAs: "stream" }));
18
+ }
19
+ catch (err) {
20
+ if (!(err instanceof BackboneApiError && err.code === "stream_not_supported"))
21
+ throw err;
22
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
23
+ const { data } = await client.POST("/v1/responses", { body: { ...body, stream: false } });
24
+ return data;
25
+ }
26
+ if (!response.body || !response.headers.get("content-type")?.includes("text/event-stream")) {
27
+ return (await response.json());
28
+ }
29
+ return readResponseStream(response.body, onStart);
30
+ }
31
+ /** How to read a run whose stream was lost: the conversation keeps every item the run wrote. */
32
+ function readHint(started) {
33
+ const conversation = started?.conversation?.id;
34
+ return conversation
35
+ ? ` The run goes on on the server; read its result with: 2kw conversations items list ${conversation}`
36
+ : "";
37
+ }
38
+ /** Reads Open Responses server-sent events up to the terminal one; comments (keepalives) are skipped. */
39
+ async function readResponseStream(body, onStart) {
40
+ let started;
41
+ let error;
42
+ const failed = () => new BackboneApiError({
43
+ status: 500,
44
+ error: error?.code ?? "run_failed",
45
+ code: error?.code ?? undefined,
46
+ message: error?.message ?? "The response failed.",
47
+ timestamp: new Date().toISOString(),
48
+ });
49
+ const onInterrupt = () => {
50
+ console.error(`Interrupted.${readHint(started)}`);
51
+ process.exit(130);
52
+ };
53
+ const handle = (frame) => {
54
+ const data = frame
55
+ .split(/\r?\n/)
56
+ .filter((line) => line.startsWith("data:"))
57
+ .map((line) => line.slice(5).trimStart())
58
+ .join("\n");
59
+ if (!data || data === "[DONE]")
60
+ return undefined;
61
+ let event;
62
+ try {
63
+ event = JSON.parse(data);
64
+ }
65
+ catch {
66
+ return undefined; // a garbled frame is skipped: the run goes on, and its terminal event still ends it
67
+ }
68
+ switch (event.type) {
69
+ case "response.created":
70
+ started = event.response;
71
+ if (started?.conversation?.id)
72
+ process.once("SIGINT", onInterrupt);
73
+ onStart?.(event.response);
74
+ return undefined;
75
+ case "error":
76
+ error = event;
77
+ return undefined;
78
+ case "response.completed":
79
+ case "response.incomplete":
80
+ return event.response;
81
+ case "response.failed":
82
+ throw failed();
83
+ default:
84
+ return undefined;
85
+ }
86
+ };
87
+ const decoder = new TextDecoder();
88
+ let buffer = "";
89
+ const reader = body.getReader();
90
+ try {
91
+ for (;;) {
92
+ let chunk;
93
+ try {
94
+ chunk = await reader.read();
95
+ }
96
+ catch (err) {
97
+ throw new Error(`The connection closed before the run finished (${err.message}).${readHint(started)}`);
98
+ }
99
+ if (chunk.done)
100
+ break;
101
+ buffer += decoder.decode(chunk.value, { stream: true });
102
+ let end;
103
+ while ((end = /\r?\n\r?\n/.exec(buffer))) {
104
+ const frame = buffer.slice(0, end.index);
105
+ buffer = buffer.slice(end.index + end[0].length);
106
+ const terminal = handle(frame);
107
+ if (terminal)
108
+ return terminal;
109
+ }
110
+ }
111
+ }
112
+ finally {
113
+ process.removeListener("SIGINT", onInterrupt);
114
+ await reader.cancel().catch(() => { });
115
+ }
116
+ if (error)
117
+ throw failed(); // an error event whose response.failed never arrived
118
+ throw new Error(`The response stream ended before the run finished.${readHint(started)}`);
119
+ }
120
+ //# sourceMappingURL=response-stream.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2kw/ai",
3
- "version": "8.0.3",
3
+ "version": "8.1.0-dev.2",
4
4
  "description": "CLI for 2kw.ai — schema-driven document extraction, an OpenAI-compatible EU LLM gateway, transcription, prompts, datasets, and experiments from your terminal or agentic workflows. Ships as 2kw, backbone, and bb.",
5
5
  "keywords": [
6
6
  "cli",