@ruska/cli 0.1.4 → 0.1.6

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
@@ -176,11 +176,34 @@ $ ruska chat "Hello" -a <assistant-id> | jq '.type'
176
176
  | `-a, --assistant` | Assistant ID (optional, uses default chat if omitted) |
177
177
  | `-t, --thread` | Thread ID to continue an existing conversation |
178
178
  | `-m, --message` | Message to send (alternative to positional arg) |
179
+ | `--tools` | Tools for the chat session (see below for modes) |
179
180
  | `--json` | Output as newline-delimited JSON (NDJSON) |
180
181
  | `--truncate <n>` | Max characters for tool output (default: 500) |
181
182
  | `--truncate-lines` | Max lines for tool output (default: 10) |
182
183
  | `--full-output` | Disable truncation (show full output) |
183
184
 
185
+ **Tool options:**
186
+
187
+ | Value | Behavior |
188
+ | --------------------- | --------------------------------------------------------------------------------------- |
189
+ | (not provided) | Uses default tools: web_search, web_scrape, math_calculator, think_tool, python_sandbox |
190
+ | `--tools=disabled` | Disables all tools |
191
+ | `--tools=tool1,tool2` | Uses only the specified tools |
192
+
193
+ **Examples with tools:**
194
+
195
+ ```bash
196
+ # Chat with default tools (web search, scrape, calculator, think, python)
197
+ $ ruska chat "What's the weather in Dallas?"
198
+
199
+ # Chat without any tools
200
+ $ ruska chat "Tell me a joke" --tools=disabled
201
+
202
+ # Chat with specific tools only
203
+ $ ruska chat "Calculate 2+2" --tools=math_calculator
204
+ $ ruska chat "Search and analyze" --tools=web_search,think_tool
205
+ ```
206
+
184
207
  **Exit codes:**
185
208
 
186
209
  | Code | Meaning |
package/dist/cli.js CHANGED
@@ -33,6 +33,9 @@ const cli = meow(`
33
33
  -a, --assistant Assistant ID (optional, uses default chat if omitted)
34
34
  -t, --thread Thread ID to continue a conversation
35
35
  -m, --message Message (alternative to positional arg)
36
+ --tools Tools to enable (default: web_search,web_scrape,math_calculator,think_tool,python_sandbox)
37
+ Use --tools=disabled to disable all tools
38
+ Use --tools=tool1,tool2 for specific tools
36
39
  --json Output as newline-delimited JSON (auto-enabled when piped)
37
40
  --truncate <n> Max characters for tool output (default: 500)
38
41
  --truncate-lines Max lines for tool output (default: 10)
@@ -50,7 +53,9 @@ const cli = meow(`
50
53
  $ ruska auth # Configure API key and host
51
54
  $ ruska assistants # List your assistants
52
55
  $ ruska assistant abc-123 # Get assistant details
53
- $ ruska chat "Hello" # Direct chat with default LLM
56
+ $ ruska chat "Hello" # Chat with default tools enabled
57
+ $ ruska chat "Hello" --tools=disabled # Chat without any tools
58
+ $ ruska chat "Search X" --tools=web_search # Chat with specific tools
54
59
  $ ruska chat "Hello" -a <assistant-id> # Chat with specific assistant
55
60
  $ ruska chat "Follow up" -t <thread-id> # Continue existing thread
56
61
  $ ruska chat "Hello" -a <id> --json # Output as NDJSON
@@ -116,7 +121,7 @@ const cli = meow(`
116
121
  },
117
122
  },
118
123
  });
119
- const [command, ...args] = cli.input;
124
+ const [command, ...arguments_] = cli.input;
120
125
  // Route to appropriate command
121
126
  async function main() {
122
127
  if (cli.flags.ui) {
@@ -134,7 +139,7 @@ async function main() {
134
139
  break;
135
140
  }
136
141
  case 'assistant': {
137
- const assistantId = args[0];
142
+ const assistantId = arguments_[0];
138
143
  if (!assistantId) {
139
144
  console.error('Usage: ruska assistant <id>');
140
145
  console.log('Run `ruska assistants` to list available assistants');
@@ -153,7 +158,7 @@ async function main() {
153
158
  cli.flags['a']?.toString();
154
159
  const threadId = cli.flags.thread ??
155
160
  cli.flags['t']?.toString();
156
- const message = args.join(' ') || cli.flags.message;
161
+ const message = arguments_.join(' ') || cli.flags.message;
157
162
  if (!message) {
158
163
  console.error('Error: Message is required');
159
164
  console.error('Usage: ruska chat "<message>" [-a <assistant-id>]');
@@ -163,6 +168,7 @@ async function main() {
163
168
  json: cli.flags.json,
164
169
  assistantId,
165
170
  threadId,
171
+ tools: cli.flags.tools,
166
172
  truncateOptions: cli.flags.fullOutput
167
173
  ? undefined
168
174
  : {
@@ -10,5 +10,6 @@ export declare function runChatCommand(message: string, options?: {
10
10
  json?: boolean;
11
11
  assistantId?: string;
12
12
  threadId?: string;
13
+ tools?: string;
13
14
  truncateOptions?: TruncateOptions;
14
15
  }): Promise<void>;
@@ -14,6 +14,7 @@ import { classifyError, exitCodes } from '../lib/output/error-handler.js';
14
14
  import { writeJson, checkIsTty } from '../lib/output/writers.js';
15
15
  import { StreamService, StreamConnectionError, } from '../lib/services/stream-service.js';
16
16
  import { truncate } from '../lib/output/truncate.js';
17
+ import { parseToolsFlag } from '../lib/tools.js';
17
18
  /**
18
19
  * Group messages into blocks by type + name boundaries
19
20
  */
@@ -83,7 +84,7 @@ function StatusIndicator({ status }) {
83
84
  /**
84
85
  * TUI mode chat command using React hook
85
86
  */
86
- function ChatCommandTui({ message, assistantId, threadId, truncateOptions, }) {
87
+ function ChatCommandTui({ message, assistantId, threadId, tools, truncateOptions, }) {
87
88
  const { exit } = useApp();
88
89
  const [config, setConfig] = useState();
89
90
  const [authError, setAuthError] = useState(false);
@@ -106,15 +107,16 @@ function ChatCommandTui({ message, assistantId, threadId, truncateOptions, }) {
106
107
  const request = useMemo(() => config
107
108
  ? {
108
109
  input: { messages: [{ role: 'user', content: message }] },
110
+ tools,
109
111
  metadata: {
110
112
  ...(assistantId && { assistant_id: assistantId }),
111
113
  ...(threadId && { thread_id: threadId }),
112
114
  },
113
115
  }
114
- : undefined, [config, assistantId, message, threadId]);
116
+ : undefined, [config, assistantId, message, threadId, tools]);
115
117
  /* eslint-enable @typescript-eslint/naming-convention */
116
118
  // Stream
117
- const { status, messages, events, error } = useStream(config, request);
119
+ const { status, messages, events, streamMetadata, error } = useStream(config, request);
118
120
  // Group messages into blocks by type + name boundaries
119
121
  const messageBlocks = useMemo(() => groupMessagesIntoBlocks(messages), [messages]);
120
122
  // Exit on completion
@@ -159,22 +161,23 @@ function ChatCommandTui({ message, assistantId, threadId, truncateOptions, }) {
159
161
  })()))) : (React.createElement(Text, null, block.content))))),
160
162
  status === 'done' && (React.createElement(Box, { marginTop: 1, flexDirection: "column" },
161
163
  React.createElement(Text, { color: "green" }, "Done"),
162
- request?.metadata?.thread_id && (React.createElement(Text, { dimColor: true },
163
- "Thread: ",
164
- request.metadata.thread_id)),
165
164
  extractModelFromEvents(events) && (React.createElement(Text, { dimColor: true },
166
165
  "Model: ",
167
166
  extractModelFromEvents(events))),
168
- request?.metadata?.thread_id && (React.createElement(Text, { dimColor: true },
169
- "Continue: ruska chat \"message\" -t",
167
+ streamMetadata?.thread_id && (React.createElement(Text, { dimColor: true },
168
+ "Thread: ",
169
+ streamMetadata.thread_id)),
170
+ streamMetadata?.thread_id && (React.createElement(Text, { dimColor: true },
171
+ "Continue: ruska chat -t ",
172
+ streamMetadata.thread_id,
170
173
  ' ',
171
- request.metadata.thread_id))))));
174
+ "\"message\""))))));
172
175
  }
173
176
  /**
174
177
  * JSON mode chat command - direct streaming without React hooks
175
178
  * Outputs NDJSON for downstream consumption
176
179
  */
177
- async function runJsonMode(message, assistantId, threadId) {
180
+ async function runJsonMode(message, assistantId, threadId, tools) {
178
181
  const config = await loadConfig();
179
182
  if (!config) {
180
183
  const formatter = new OutputFormatter();
@@ -190,6 +193,7 @@ async function runJsonMode(message, assistantId, threadId) {
190
193
  /* eslint-disable @typescript-eslint/naming-convention */
191
194
  const request = {
192
195
  input: { messages: [{ role: 'user', content: message }] },
196
+ tools,
193
197
  metadata: {
194
198
  ...(assistantId && { assistant_id: assistantId }),
195
199
  ...(threadId && { thread_id: threadId }),
@@ -244,22 +248,22 @@ async function runJsonMode(message, assistantId, threadId) {
244
248
  /**
245
249
  * Main chat command component - handles JSON mode branching
246
250
  */
247
- function ChatCommand({ message, isJsonMode, assistantId, threadId, truncateOptions, }) {
251
+ function ChatCommand({ message, isJsonMode, assistantId, threadId, tools, truncateOptions, }) {
248
252
  const { exit } = useApp();
249
253
  useEffect(() => {
250
254
  if (isJsonMode) {
251
255
  // JSON mode runs outside React, just exit immediately
252
- void runJsonMode(message, assistantId, threadId).finally(() => {
256
+ void runJsonMode(message, assistantId, threadId, tools).finally(() => {
253
257
  exit();
254
258
  });
255
259
  }
256
- }, [message, isJsonMode, assistantId, threadId, exit]);
260
+ }, [message, isJsonMode, assistantId, threadId, tools, exit]);
257
261
  // JSON mode: no UI (handled in useEffect)
258
262
  if (isJsonMode) {
259
263
  return null;
260
264
  }
261
265
  // TUI mode
262
- return (React.createElement(ChatCommandTui, { message: message, assistantId: assistantId, threadId: threadId, truncateOptions: truncateOptions }));
266
+ return (React.createElement(ChatCommandTui, { message: message, assistantId: assistantId, threadId: threadId, tools: tools, truncateOptions: truncateOptions }));
263
267
  }
264
268
  /**
265
269
  * Run the chat command
@@ -267,6 +271,8 @@ function ChatCommand({ message, isJsonMode, assistantId, threadId, truncateOptio
267
271
  export async function runChatCommand(message, options = {}) {
268
272
  // Auto-detect: use JSON mode if not TTY (piped) or explicitly requested
269
273
  const isJsonMode = options.json ?? !checkIsTty();
270
- const { waitUntilExit } = render(React.createElement(ChatCommand, { message: message, isJsonMode: isJsonMode, assistantId: options.assistantId, threadId: options.threadId, truncateOptions: options.truncateOptions }));
274
+ // Parse tools flag (undefined = defaults, 'disabled' = [], 'a,b' = ['a', 'b'])
275
+ const parsedTools = parseToolsFlag(options.tools);
276
+ const { waitUntilExit } = render(React.createElement(ChatCommand, { message: message, isJsonMode: isJsonMode, assistantId: options.assistantId, threadId: options.threadId, tools: parsedTools, truncateOptions: options.truncateOptions }));
271
277
  await waitUntilExit();
272
278
  }
@@ -1,10 +1,10 @@
1
1
  import React from 'react';
2
- type ModelSelectProps = {
2
+ type ModelSelectProperties = {
3
3
  readonly models: string[];
4
4
  readonly value: string;
5
5
  readonly onChange: (value: string) => void;
6
6
  readonly onSubmit: (value: string) => void;
7
7
  readonly onEscape?: () => void;
8
8
  };
9
- export declare function ModelSelect({ models, value, onChange, onSubmit, onEscape, }: ModelSelectProps): React.JSX.Element;
9
+ export declare function ModelSelect({ models, value, onChange, onSubmit, onEscape, }: ModelSelectProperties): React.JSX.Element;
10
10
  export {};
@@ -61,8 +61,8 @@ export function ModelSelect({ models, value, onChange, onSubmit, onEscape, }) {
61
61
  " \u2191 ",
62
62
  startIndex,
63
63
  " more"),
64
- visibleModels.map((model, idx) => {
65
- const actualIndex = startIndex + idx;
64
+ visibleModels.map((model, index) => {
65
+ const actualIndex = startIndex + index;
66
66
  const isSelected = actualIndex === selectedIndex;
67
67
  return (React.createElement(Box, { key: model },
68
68
  React.createElement(Text, { color: isSelected ? 'cyan' : undefined }, isSelected ? '❯ ' : ' '),
@@ -3,13 +3,14 @@
3
3
  * From Beta proposal with critical values event handling
4
4
  */
5
5
  import type { Config } from '../types/index.js';
6
- import { type MessagePayload, type StreamRequest, type StreamEvent, type ValuesPayload } from '../types/stream.js';
6
+ import { type MessagePayload, type MetadataPayload, type StreamRequest, type StreamEvent, type ValuesPayload } from '../types/stream.js';
7
7
  export type StreamStatus = 'idle' | 'connecting' | 'streaming' | 'done' | 'error';
8
8
  export type UseStreamResult = {
9
9
  status: StreamStatus;
10
10
  events: StreamEvent[];
11
11
  messages: MessagePayload[];
12
12
  finalResponse: ValuesPayload | undefined;
13
+ streamMetadata: MetadataPayload | undefined;
13
14
  error: string | undefined;
14
15
  errorCode: number | undefined;
15
16
  abort: () => void;
@@ -3,7 +3,7 @@
3
3
  * From Beta proposal with critical values event handling
4
4
  */
5
5
  import { useState, useEffect, useRef, useCallback } from 'react';
6
- import { StreamService, StreamConnectionError, } from '../lib/services/stream-service.js';
6
+ import { StreamService, StreamConnectionError, DistributedStreamError, MalformedDistributedResponse, } from '../lib/services/stream-service.js';
7
7
  /**
8
8
  * React hook for consuming LLM streams.
9
9
  * CRITICAL: Captures "values" event payload as finalResponse
@@ -13,11 +13,12 @@ export function useStream(config, request) {
13
13
  const [events, setEvents] = useState([]);
14
14
  const [messages, setMessages] = useState([]);
15
15
  const [finalResponse, setFinalResponse] = useState();
16
+ const [streamMetadata, setStreamMetadata] = useState();
16
17
  const [error, setError] = useState();
17
18
  const [errorCode, setErrorCode] = useState();
18
- const handleRef = useRef();
19
+ const handleReference = useRef();
19
20
  const abort = useCallback(() => {
20
- handleRef.current?.abort();
21
+ handleReference.current?.abort();
21
22
  }, []);
22
23
  useEffect(() => {
23
24
  if (!config || !request)
@@ -29,11 +30,12 @@ export function useStream(config, request) {
29
30
  setEvents([]);
30
31
  setMessages([]);
31
32
  setFinalResponse(undefined);
33
+ setStreamMetadata(undefined);
32
34
  setError(undefined);
33
35
  setErrorCode(undefined);
34
36
  try {
35
37
  const handle = await service.connect(request);
36
- handleRef.current = handle;
38
+ handleReference.current = handle;
37
39
  if (cancelled) {
38
40
  handle.abort();
39
41
  return;
@@ -58,11 +60,21 @@ export function useStream(config, request) {
58
60
  setFinalResponse(event.payload);
59
61
  break;
60
62
  }
63
+ case 'metadata': {
64
+ // Capture stream metadata (thread_id, etc.)
65
+ setStreamMetadata(event.payload);
66
+ break;
67
+ }
61
68
  case 'error': {
62
69
  setError(event.payload.message);
63
70
  setStatus('error');
64
71
  return;
65
72
  }
73
+ case 'done': {
74
+ // Terminal event from distributed mode
75
+ // Stream will end naturally after this
76
+ break;
77
+ }
66
78
  default: {
67
79
  // Unknown event type - ignore
68
80
  break;
@@ -77,6 +89,12 @@ export function useStream(config, request) {
77
89
  setError(error_.message);
78
90
  setErrorCode(error_.statusCode);
79
91
  }
92
+ else if (error_ instanceof DistributedStreamError) {
93
+ setError(`Distributed error (${error_.phase}): ${error_.message}`);
94
+ }
95
+ else if (error_ instanceof MalformedDistributedResponse) {
96
+ setError(`Invalid distributed response: ${error_.message}`);
97
+ }
80
98
  else if (error_ instanceof Error) {
81
99
  setError(error_.message);
82
100
  }
@@ -90,7 +108,7 @@ export function useStream(config, request) {
90
108
  void run();
91
109
  return () => {
92
110
  cancelled = true;
93
- handleRef.current?.abort();
111
+ handleReference.current?.abort();
94
112
  };
95
113
  }, [config, request]);
96
114
  return {
@@ -98,6 +116,7 @@ export function useStream(config, request) {
98
116
  events,
99
117
  messages,
100
118
  finalResponse,
119
+ streamMetadata,
101
120
  error,
102
121
  errorCode,
103
122
  abort,
@@ -19,6 +19,8 @@ export declare const exitCodes: {
19
19
  readonly rateLimited: 3;
20
20
  readonly timeout: 4;
21
21
  readonly serverError: 5;
22
+ readonly distributedError: 6;
23
+ readonly distributedTimeout: 7;
22
24
  };
23
25
  /**
24
26
  * Map raw errors to structured error responses
@@ -2,6 +2,7 @@
2
2
  * Error handler with taxonomy for CLI output
3
3
  * From Gamma proposal with structured error codes
4
4
  */
5
+ import { DistributedStreamError, MalformedDistributedResponse, } from '../services/stream-service.js';
5
6
  /**
6
7
  * Exit codes for scripting
7
8
  */
@@ -12,6 +13,8 @@ export const exitCodes = {
12
13
  rateLimited: 3,
13
14
  timeout: 4,
14
15
  serverError: 5,
16
+ distributedError: 6,
17
+ distributedTimeout: 7,
15
18
  };
16
19
  /**
17
20
  * Map raw errors to structured error responses
@@ -45,6 +48,33 @@ export function classifyError(error, statusCode) {
45
48
  }
46
49
  }
47
50
  if (error instanceof Error) {
51
+ // Handle distributed mode specific errors
52
+ if (error instanceof DistributedStreamError) {
53
+ // Check if it's a timeout during distributed streaming
54
+ if (error.message.includes('timeout') ||
55
+ error.message.includes('aborted')) {
56
+ return {
57
+ code: 'DISTRIBUTED_TIMEOUT',
58
+ message: `Distributed stream timed out during ${error.phase} phase.`,
59
+ recoverable: true,
60
+ exitCode: exitCodes.distributedTimeout,
61
+ };
62
+ }
63
+ return {
64
+ code: 'DISTRIBUTED_ERROR',
65
+ message: `Distributed stream error (${error.phase}): ${error.message}`,
66
+ recoverable: error.phase === 'handshake',
67
+ exitCode: exitCodes.distributedError,
68
+ };
69
+ }
70
+ if (error instanceof MalformedDistributedResponse) {
71
+ return {
72
+ code: 'DISTRIBUTED_ERROR',
73
+ message: `Invalid distributed response: ${error.message}`,
74
+ recoverable: false,
75
+ exitCode: exitCodes.distributedError,
76
+ };
77
+ }
48
78
  const errorMessage = error.message.toLowerCase();
49
79
  // Network errors
50
80
  if (errorMessage.includes('fetch failed') ||
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Stream service implementation for SSE consumption
3
- * Combines Beta's architecture with Alpha's timeout handling
3
+ * Supports both sync mode (HTTP 200) and distributed worker mode (HTTP 202)
4
+ * @see backend/src/routes/v0/llm.py - distributed mode detection
5
+ * @see backend/src/routes/v0/thread.py - thread stream endpoint
4
6
  */
5
7
  import type { Config } from '../../types/index.js';
6
- import type { StreamRequest, StreamHandle } from '../../types/stream.js';
8
+ import { type StreamRequest, type StreamHandle } from '../../types/stream.js';
7
9
  import type { StreamServiceInterface } from './stream-service.interface.js';
8
10
  /**
9
11
  * Custom error for stream connection failures
@@ -12,16 +14,58 @@ export declare class StreamConnectionError extends Error {
12
14
  readonly statusCode: number;
13
15
  constructor(message: string, statusCode: number);
14
16
  }
17
+ /**
18
+ * Error specific to distributed streaming mode
19
+ * Includes phase information for debugging
20
+ */
21
+ export declare class DistributedStreamError extends Error {
22
+ readonly phase: 'handshake' | 'streaming';
23
+ readonly rawResponse?: string | undefined;
24
+ constructor(message: string, phase: 'handshake' | 'streaming', rawResponse?: string | undefined);
25
+ }
26
+ /**
27
+ * Error for malformed distributed response from initial POST
28
+ */
29
+ export declare class MalformedDistributedResponse extends Error {
30
+ readonly rawResponse?: string | undefined;
31
+ constructor(message: string, rawResponse?: string | undefined);
32
+ }
15
33
  /**
16
34
  * Production stream service using SSE
17
35
  */
18
36
  export declare class StreamService implements StreamServiceInterface {
19
37
  private readonly host;
20
38
  private readonly apiKey;
21
- private readonly timeoutMs;
22
- constructor(config: Config, timeoutMs?: number);
39
+ private readonly connectionTimeoutMs;
40
+ private readonly streamIdleTimeoutMs;
41
+ constructor(config: Config, connectionTimeoutMs?: number, streamIdleTimeoutMs?: number);
23
42
  connect(request: StreamRequest): Promise<StreamHandle>;
24
43
  private fetchStream;
44
+ /**
45
+ * Handle distributed worker mode (HTTP 202 response)
46
+ * Parses the initial response and connects to thread stream endpoint
47
+ */
48
+ private handleDistributedMode;
49
+ /**
50
+ * Connect to GET /api/threads/{thread_id}/stream for distributed mode
51
+ * URL-encodes thread_id to prevent path injection
52
+ */
53
+ private connectToThreadStream;
54
+ /**
55
+ * Parse SSE events from distributed worker stream
56
+ * Handles [DONE] marker, keep-alive comments, and error JSON
57
+ */
58
+ private parseDistributedEventStream;
59
+ /**
60
+ * Extract events from distributed stream buffer
61
+ * Handles keep-alive comments, [DONE] marker, and error JSON
62
+ */
63
+ private extractDistributedEvents;
64
+ /**
65
+ * Parse a single distributed event data payload
66
+ * Handles error JSON format and regular event format
67
+ */
68
+ private parseDistributedEvent;
25
69
  private parseEventStream;
26
70
  private extractEvents;
27
71
  private parseEvent;
@@ -1,8 +1,12 @@
1
1
  /**
2
2
  * Stream service implementation for SSE consumption
3
- * Combines Beta's architecture with Alpha's timeout handling
3
+ * Supports both sync mode (HTTP 200) and distributed worker mode (HTTP 202)
4
+ * @see backend/src/routes/v0/llm.py - distributed mode detection
5
+ * @see backend/src/routes/v0/thread.py - thread stream endpoint
4
6
  */
5
- const defaultTimeoutMs = 30000;
7
+ import { isDistributedResponse, isDistributedError, STREAM_DONE_MARKER, } from '../../types/stream.js';
8
+ const defaultConnectionTimeoutMs = 30000;
9
+ const defaultStreamIdleTimeoutMs = 60000;
6
10
  /**
7
11
  * Custom error for stream connection failures
8
12
  */
@@ -18,11 +22,48 @@ export class StreamConnectionError extends Error {
18
22
  this.name = 'StreamConnectionError';
19
23
  }
20
24
  }
25
+ /**
26
+ * Error specific to distributed streaming mode
27
+ * Includes phase information for debugging
28
+ */
29
+ export class DistributedStreamError extends Error {
30
+ constructor(message, phase, rawResponse) {
31
+ super(message);
32
+ Object.defineProperty(this, "phase", {
33
+ enumerable: true,
34
+ configurable: true,
35
+ writable: true,
36
+ value: phase
37
+ });
38
+ Object.defineProperty(this, "rawResponse", {
39
+ enumerable: true,
40
+ configurable: true,
41
+ writable: true,
42
+ value: rawResponse
43
+ });
44
+ this.name = 'DistributedStreamError';
45
+ }
46
+ }
47
+ /**
48
+ * Error for malformed distributed response from initial POST
49
+ */
50
+ export class MalformedDistributedResponse extends Error {
51
+ constructor(message, rawResponse) {
52
+ super(message);
53
+ Object.defineProperty(this, "rawResponse", {
54
+ enumerable: true,
55
+ configurable: true,
56
+ writable: true,
57
+ value: rawResponse
58
+ });
59
+ this.name = 'MalformedDistributedResponse';
60
+ }
61
+ }
21
62
  /**
22
63
  * Production stream service using SSE
23
64
  */
24
65
  export class StreamService {
25
- constructor(config, timeoutMs = defaultTimeoutMs) {
66
+ constructor(config, connectionTimeoutMs = defaultConnectionTimeoutMs, streamIdleTimeoutMs = defaultStreamIdleTimeoutMs) {
26
67
  Object.defineProperty(this, "host", {
27
68
  enumerable: true,
28
69
  configurable: true,
@@ -35,7 +76,13 @@ export class StreamService {
35
76
  writable: true,
36
77
  value: void 0
37
78
  });
38
- Object.defineProperty(this, "timeoutMs", {
79
+ Object.defineProperty(this, "connectionTimeoutMs", {
80
+ enumerable: true,
81
+ configurable: true,
82
+ writable: true,
83
+ value: void 0
84
+ });
85
+ Object.defineProperty(this, "streamIdleTimeoutMs", {
39
86
  enumerable: true,
40
87
  configurable: true,
41
88
  writable: true,
@@ -43,17 +90,23 @@ export class StreamService {
43
90
  });
44
91
  this.host = config.host.replace(/\/$/, '');
45
92
  this.apiKey = config.apiKey;
46
- this.timeoutMs = timeoutMs;
93
+ this.connectionTimeoutMs = connectionTimeoutMs;
94
+ this.streamIdleTimeoutMs = streamIdleTimeoutMs;
47
95
  }
48
96
  async connect(request) {
49
97
  const controller = new AbortController();
50
98
  // Set timeout for connection
51
99
  const timeoutId = setTimeout(() => {
52
100
  controller.abort();
53
- }, this.timeoutMs);
101
+ }, this.connectionTimeoutMs);
54
102
  try {
55
103
  const response = await this.fetchStream(request, controller.signal);
56
104
  clearTimeout(timeoutId);
105
+ // Check for distributed mode (HTTP 202)
106
+ if (response.status === 202) {
107
+ return await this.handleDistributedMode(response, controller);
108
+ }
109
+ // Sync mode (HTTP 200)
57
110
  if (!response.body) {
58
111
  throw new StreamConnectionError('No response body', 0);
59
112
  }
@@ -83,12 +136,207 @@ export class StreamService {
83
136
  body: JSON.stringify(request),
84
137
  signal,
85
138
  });
86
- if (!response.ok) {
139
+ // Accept both 200 (sync) and 202 (distributed) as valid
140
+ if (!response.ok && response.status !== 202) {
87
141
  const error = await this.parseError(response);
88
142
  throw new StreamConnectionError(error, response.status);
89
143
  }
90
144
  return response;
91
145
  }
146
+ /**
147
+ * Handle distributed worker mode (HTTP 202 response)
148
+ * Parses the initial response and connects to thread stream endpoint
149
+ */
150
+ async handleDistributedMode(initialResponse, controller) {
151
+ // Parse the distributed response body
152
+ const rawText = await initialResponse.text();
153
+ let data;
154
+ try {
155
+ data = JSON.parse(rawText);
156
+ }
157
+ catch {
158
+ throw new MalformedDistributedResponse('Invalid JSON in distributed response', rawText);
159
+ }
160
+ // Validate the response shape
161
+ if (!isDistributedResponse(data)) {
162
+ throw new MalformedDistributedResponse('Invalid distributed response: missing or invalid thread_id', rawText);
163
+ }
164
+ // Check if already aborted before making second request
165
+ if (controller.signal.aborted) {
166
+ throw new DOMException('Aborted', 'AbortError');
167
+ }
168
+ // Connect to the thread stream endpoint
169
+ const streamHandle = await this.connectToThreadStream(data.thread_id, controller);
170
+ return streamHandle;
171
+ }
172
+ /**
173
+ * Connect to GET /api/threads/{thread_id}/stream for distributed mode
174
+ * URL-encodes thread_id to prevent path injection
175
+ */
176
+ async connectToThreadStream(threadId, controller) {
177
+ // URL-encode thread_id to prevent path injection
178
+ const encodedThreadId = encodeURIComponent(threadId);
179
+ const streamUrl = `${this.host}/api/threads/${encodedThreadId}/stream`;
180
+ // Set connection timeout for the GET request
181
+ const connectionTimeoutId = setTimeout(() => {
182
+ controller.abort();
183
+ }, this.connectionTimeoutMs);
184
+ try {
185
+ const streamResponse = await fetch(streamUrl, {
186
+ method: 'GET',
187
+ headers: {
188
+ 'x-api-key': this.apiKey,
189
+ // eslint-disable-next-line @typescript-eslint/naming-convention
190
+ Accept: 'text/event-stream',
191
+ },
192
+ signal: controller.signal,
193
+ });
194
+ clearTimeout(connectionTimeoutId);
195
+ if (!streamResponse.ok) {
196
+ const error = await this.parseError(streamResponse);
197
+ throw new DistributedStreamError(`Failed to connect to thread stream: ${error}`, 'handshake');
198
+ }
199
+ if (!streamResponse.body) {
200
+ throw new DistributedStreamError('No response body from thread stream', 'handshake');
201
+ }
202
+ const events = this.parseDistributedEventStream(streamResponse.body, controller);
203
+ return {
204
+ events,
205
+ abort() {
206
+ controller.abort();
207
+ },
208
+ };
209
+ }
210
+ catch (error) {
211
+ clearTimeout(connectionTimeoutId);
212
+ throw error;
213
+ }
214
+ }
215
+ /**
216
+ * Parse SSE events from distributed worker stream
217
+ * Handles [DONE] marker, keep-alive comments, and error JSON
218
+ */
219
+ async *parseDistributedEventStream(body, controller) {
220
+ const reader = body.getReader();
221
+ const decoder = new TextDecoder();
222
+ let buffer = '';
223
+ let idleTimeoutId;
224
+ // Reset idle timeout on data
225
+ const resetIdleTimeout = () => {
226
+ if (idleTimeoutId) {
227
+ clearTimeout(idleTimeoutId);
228
+ }
229
+ idleTimeoutId = setTimeout(() => {
230
+ controller.abort();
231
+ }, this.streamIdleTimeoutMs);
232
+ };
233
+ try {
234
+ resetIdleTimeout();
235
+ while (true) {
236
+ // eslint-disable-next-line no-await-in-loop
237
+ const { done, value } = await reader.read();
238
+ if (done)
239
+ break;
240
+ // Reset idle timeout on any data received
241
+ resetIdleTimeout();
242
+ buffer += decoder.decode(value, { stream: true });
243
+ const result = this.extractDistributedEvents(buffer);
244
+ buffer = result.remaining;
245
+ for (const event of result.parsed) {
246
+ // Check for terminal [DONE] marker
247
+ if (event.type === 'done') {
248
+ return;
249
+ }
250
+ yield event;
251
+ }
252
+ }
253
+ // Process any remaining buffer
254
+ if (buffer.trim()) {
255
+ const result = this.extractDistributedEvents(buffer + '\n');
256
+ for (const event of result.parsed) {
257
+ if (event.type === 'done') {
258
+ return;
259
+ }
260
+ yield event;
261
+ }
262
+ }
263
+ }
264
+ finally {
265
+ if (idleTimeoutId) {
266
+ clearTimeout(idleTimeoutId);
267
+ }
268
+ reader.releaseLock();
269
+ }
270
+ }
271
+ /**
272
+ * Extract events from distributed stream buffer
273
+ * Handles keep-alive comments, [DONE] marker, and error JSON
274
+ */
275
+ extractDistributedEvents(buffer) {
276
+ const parsed = [];
277
+ const lines = buffer.split('\n');
278
+ let remaining = '';
279
+ for (let i = 0; i < lines.length; i++) {
280
+ const line = lines[i];
281
+ // Incomplete line at end (no newline after it)
282
+ if (i === lines.length - 1 && !buffer.endsWith('\n')) {
283
+ remaining = line;
284
+ break;
285
+ }
286
+ // Skip empty lines (SSE event separator)
287
+ if (line.trim() === '') {
288
+ continue;
289
+ }
290
+ // Skip keep-alive comments (lines starting with :)
291
+ if (line.startsWith(':')) {
292
+ continue;
293
+ }
294
+ // Parse data lines
295
+ if (line.startsWith('data: ')) {
296
+ const data = line.slice(6);
297
+ // Check for [DONE] marker
298
+ if (data === STREAM_DONE_MARKER) {
299
+ parsed.push({ type: 'done', payload: undefined });
300
+ continue;
301
+ }
302
+ // Try to parse as JSON
303
+ const event = this.parseDistributedEvent(data);
304
+ if (event) {
305
+ parsed.push(event);
306
+ }
307
+ }
308
+ }
309
+ return { parsed, remaining };
310
+ }
311
+ /**
312
+ * Parse a single distributed event data payload
313
+ * Handles error JSON format and regular event format
314
+ */
315
+ parseDistributedEvent(data) {
316
+ try {
317
+ const parsed = JSON.parse(data);
318
+ // Check for distributed error format: {"error": "..."}
319
+ if (isDistributedError(parsed)) {
320
+ return {
321
+ type: 'error',
322
+ payload: { message: parsed.error },
323
+ };
324
+ }
325
+ // Regular event format: [type, payload]
326
+ if (Array.isArray(parsed) && parsed.length >= 2) {
327
+ const [type, payload] = parsed;
328
+ // Type assertion needed because we're parsing dynamic SSE data
329
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
330
+ return { type, payload };
331
+ }
332
+ // Unknown format - skip
333
+ return undefined;
334
+ }
335
+ catch {
336
+ // Parse error - skip this event
337
+ return undefined;
338
+ }
339
+ }
92
340
  async *parseEventStream(body) {
93
341
  const reader = body.getReader();
94
342
  const decoder = new TextDecoder();
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Default tools and parsing for chat command
3
+ * @see frontend/src/components/menus/BaseToolMenu.tsx for frontend equivalent
4
+ */
5
+ /**
6
+ * Default tools enabled for chat command
7
+ * Mirrors frontend DEFAULT_AGENT_TOOLS in BaseToolMenu.tsx
8
+ */
9
+ export declare const defaultAgentTools: readonly ["web_search", "web_scrape", "math_calculator", "think_tool", "python_sandbox"];
10
+ export type DefaultAgentTool = (typeof defaultAgentTools)[number];
11
+ /**
12
+ * Parse --tools flag value into array of tool names
13
+ *
14
+ * @param value - Raw flag value (undefined, 'disabled', or comma-separated)
15
+ * @returns Array of tool names to send to API
16
+ *
17
+ * @example
18
+ * parseToolsFlag(undefined) // returns DEFAULT_AGENT_TOOLS
19
+ * parseToolsFlag('') // returns DEFAULT_AGENT_TOOLS
20
+ * parseToolsFlag('disabled') // returns []
21
+ * parseToolsFlag('web_search,think_tool') // returns ['web_search', 'think_tool']
22
+ */
23
+ export declare function parseToolsFlag(value: string | undefined): string[];
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Default tools and parsing for chat command
3
+ * @see frontend/src/components/menus/BaseToolMenu.tsx for frontend equivalent
4
+ */
5
+ /**
6
+ * Default tools enabled for chat command
7
+ * Mirrors frontend DEFAULT_AGENT_TOOLS in BaseToolMenu.tsx
8
+ */
9
+ export const defaultAgentTools = [
10
+ 'web_search',
11
+ 'web_scrape',
12
+ 'math_calculator',
13
+ 'think_tool',
14
+ 'python_sandbox',
15
+ ];
16
+ /**
17
+ * Parse --tools flag value into array of tool names
18
+ *
19
+ * @param value - Raw flag value (undefined, 'disabled', or comma-separated)
20
+ * @returns Array of tool names to send to API
21
+ *
22
+ * @example
23
+ * parseToolsFlag(undefined) // returns DEFAULT_AGENT_TOOLS
24
+ * parseToolsFlag('') // returns DEFAULT_AGENT_TOOLS
25
+ * parseToolsFlag('disabled') // returns []
26
+ * parseToolsFlag('web_search,think_tool') // returns ['web_search', 'think_tool']
27
+ */
28
+ export function parseToolsFlag(value) {
29
+ // No flag or empty string: use defaults
30
+ if (value === undefined || value.trim() === '') {
31
+ return [...defaultAgentTools];
32
+ }
33
+ // Explicit disable
34
+ if (value.toLowerCase() === 'disabled') {
35
+ return [];
36
+ }
37
+ // Parse comma-separated list
38
+ return value
39
+ .split(',')
40
+ .map(t => t.trim())
41
+ .filter(Boolean);
42
+ }
@@ -37,7 +37,7 @@ export type ErrorOutput = {
37
37
  /**
38
38
  * Typed error codes for programmatic handling
39
39
  */
40
- export type ErrorCode = 'AUTH_FAILED' | 'NETWORK_ERROR' | 'RATE_LIMITED' | 'INVALID_REQUEST' | 'STREAM_INTERRUPTED' | 'PARSE_ERROR' | 'SERVER_ERROR' | 'TIMEOUT';
40
+ export type ErrorCode = 'AUTH_FAILED' | 'NETWORK_ERROR' | 'RATE_LIMITED' | 'INVALID_REQUEST' | 'STREAM_INTERRUPTED' | 'PARSE_ERROR' | 'SERVER_ERROR' | 'TIMEOUT' | 'DISTRIBUTED_ERROR' | 'DISTRIBUTED_TIMEOUT';
41
41
  /**
42
42
  * Union of all output types
43
43
  */
@@ -5,7 +5,33 @@
5
5
  /**
6
6
  * Stream event types matching backend SSE format
7
7
  */
8
- export type StreamEventType = 'messages' | 'values' | 'error';
8
+ export type StreamEventType = 'messages' | 'values' | 'error' | 'metadata' | 'done';
9
+ /**
10
+ * Special SSE markers from distributed stream
11
+ */
12
+ export declare const STREAM_DONE_MARKER = "[DONE]";
13
+ /**
14
+ * Response from POST /llm/stream when DISTRIBUTED_WORKERS=true
15
+ */
16
+ export type DistributedResponse = {
17
+ thread_id: string;
18
+ distributed: true;
19
+ };
20
+ /**
21
+ * Type guard for distributed response with strict validation
22
+ * Validates thread_id exists, is non-empty, and within bounds
23
+ */
24
+ export declare function isDistributedResponse(data: unknown): data is DistributedResponse;
25
+ /**
26
+ * Error response format from distributed stream
27
+ */
28
+ export type DistributedErrorResponse = {
29
+ error: string;
30
+ };
31
+ /**
32
+ * Type guard for distributed error format
33
+ */
34
+ export declare function isDistributedError(data: unknown): data is DistributedErrorResponse;
9
35
  /**
10
36
  * Content block for multi-modal messages
11
37
  */
@@ -49,6 +75,15 @@ export type ValuesPayload = {
49
75
  export type ErrorPayload = {
50
76
  message: string;
51
77
  };
78
+ /**
79
+ * Metadata payload from 'metadata' events
80
+ * Sent at the start of stream with thread/session info
81
+ */
82
+ export type MetadataPayload = {
83
+ thread_id?: string;
84
+ assistant_id?: string;
85
+ project_id?: string;
86
+ };
52
87
  /**
53
88
  * Discriminated union for stream events
54
89
  */
@@ -62,6 +97,12 @@ export type StreamEvent = {
62
97
  } | {
63
98
  type: 'error';
64
99
  payload: ErrorPayload;
100
+ } | {
101
+ type: 'metadata';
102
+ payload: MetadataPayload;
103
+ } | {
104
+ type: 'done';
105
+ payload: undefined;
65
106
  };
66
107
  /**
67
108
  * Request body for /llm/stream endpoint
@@ -2,6 +2,43 @@
2
2
  * Stream types for /api/llm/stream endpoint
3
3
  * @see backend/src/utils/stream.py:handle_multi_mode
4
4
  */
5
+ /**
6
+ * Special SSE markers from distributed stream
7
+ */
8
+ // eslint-disable-next-line @typescript-eslint/naming-convention
9
+ export const STREAM_DONE_MARKER = '[DONE]';
10
+ /**
11
+ * Type guard for distributed response with strict validation
12
+ * Validates thread_id exists, is non-empty, and within bounds
13
+ */
14
+ export function isDistributedResponse(data) {
15
+ if (typeof data !== 'object' || data === null) {
16
+ return false;
17
+ }
18
+ const obj = data;
19
+ if (obj['distributed'] !== true) {
20
+ return false;
21
+ }
22
+ const threadId = obj['thread_id'];
23
+ if (typeof threadId !== 'string') {
24
+ return false;
25
+ }
26
+ // Strict validation: non-empty and bounded length (prevent DoS)
27
+ if (threadId.length === 0 || threadId.length > 256) {
28
+ return false;
29
+ }
30
+ return true;
31
+ }
32
+ /**
33
+ * Type guard for distributed error format
34
+ */
35
+ export function isDistributedError(data) {
36
+ if (typeof data !== 'object' || data === null) {
37
+ return false;
38
+ }
39
+ const obj = data;
40
+ return typeof obj['error'] === 'string';
41
+ }
5
42
  /**
6
43
  * Extract text content from a MessagePayload.
7
44
  * Handles both string content and multi-modal content blocks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ruska/cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "CLI for Orchestra - AI Agent Orchestration Platform",
5
5
  "license": "Apache-2.0",
6
6
  "author": "RUSKA <reggleston@ruska.ai>",
@@ -47,7 +47,7 @@
47
47
  "dev": "tsc --watch",
48
48
  "build": "tsc",
49
49
  "build:clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && npm run build",
50
- "format": "prettier --write \"**/*.{ts,tsx,json,md}\" && xo --fix",
50
+ "format": "xo --fix && prettier --write \"**/*.{ts,tsx,json,md}\"",
51
51
  "format:check": "prettier --check .",
52
52
  "lint": "xo",
53
53
  "test": "npm run lint && npm run build && ava",
@@ -87,7 +87,7 @@
87
87
  "ts-node": "^10.9.1",
88
88
  "tsx": "^4.21.0",
89
89
  "typescript": "^5.0.3",
90
- "xo": "^0.53.1"
90
+ "xo": "^0.60.0"
91
91
  },
92
92
  "ava": {
93
93
  "files": [
@@ -103,7 +103,16 @@
103
103
  "rules": {
104
104
  "react/prop-types": "off",
105
105
  "unicorn/expiring-todo-comments": "off",
106
- "ava/no-ignored-test-files": "off"
106
+ "ava/no-ignored-test-files": "off",
107
+ "unicorn/prevent-abbreviations": "off",
108
+ "@typescript-eslint/switch-exhaustiveness-check": "off",
109
+ "@typescript-eslint/no-unsafe-assignment": "off",
110
+ "@typescript-eslint/no-unsafe-call": "off",
111
+ "@typescript-eslint/restrict-plus-operands": "off",
112
+ "promise/prefer-await-to-then": "off",
113
+ "complexity": "off",
114
+ "unicorn/prefer-at": "off",
115
+ "prettier/prettier": "off"
107
116
  }
108
117
  },
109
118
  "prettier": "@vdemedes/prettier-config"