bone-agent 1.4.0 → 2.0.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.
Files changed (126) hide show
  1. package/bin/bone.js +39 -0
  2. package/package.json +25 -39
  3. package/LICENSE +0 -21
  4. package/README.md +0 -201
  5. package/bin/npm-wrapper.js +0 -235
  6. package/bin/rg +0 -0
  7. package/bin/rg.exe +0 -0
  8. package/config.yaml.example +0 -144
  9. package/prompts/main/ask_questions.md +0 -31
  10. package/prompts/main/batch_independent_calls.md +0 -5
  11. package/prompts/main/casual_interactions.md +0 -11
  12. package/prompts/main/code_references.md +0 -8
  13. package/prompts/main/communication_style.md +0 -12
  14. package/prompts/main/context_reliability.md +0 -12
  15. package/prompts/main/conversational_tool_calling.md +0 -15
  16. package/prompts/main/dream.md +0 -50
  17. package/prompts/main/editing_pattern.md +0 -13
  18. package/prompts/main/error_handling.md +0 -6
  19. package/prompts/main/exploration_pattern.md +0 -21
  20. package/prompts/main/intro.md +0 -1
  21. package/prompts/main/obsidian.md +0 -16
  22. package/prompts/main/obsidian_project.md +0 -79
  23. package/prompts/main/professional_objectivity.md +0 -3
  24. package/prompts/main/skills.md +0 -3
  25. package/prompts/main/targeted_searching.md +0 -10
  26. package/prompts/main/task_lists_pattern.md +0 -8
  27. package/prompts/main/temp_folder.md +0 -9
  28. package/prompts/main/think_before_acting.md +0 -10
  29. package/prompts/main/tone_and_style.md +0 -4
  30. package/prompts/main/tool_preferences.md +0 -24
  31. package/prompts/main/trust_subagent_context.md +0 -21
  32. package/prompts/main/when_to_use_sub_agent.md +0 -7
  33. package/prompts/micro/ask_questions.md +0 -1
  34. package/prompts/micro/batch_independent_calls.md +0 -1
  35. package/prompts/micro/casual_interactions.md +0 -1
  36. package/prompts/micro/code_references.md +0 -1
  37. package/prompts/micro/communication_style.md +0 -1
  38. package/prompts/micro/context_reliability.md +0 -1
  39. package/prompts/micro/conversational_tool_calling.md +0 -1
  40. package/prompts/micro/editing_pattern.md +0 -1
  41. package/prompts/micro/error_handling.md +0 -1
  42. package/prompts/micro/exploration_pattern.md +0 -1
  43. package/prompts/micro/intro.md +0 -1
  44. package/prompts/micro/obsidian.md +0 -4
  45. package/prompts/micro/obsidian_project.md +0 -5
  46. package/prompts/micro/professional_objectivity.md +0 -1
  47. package/prompts/micro/skills.md +0 -1
  48. package/prompts/micro/targeted_searching.md +0 -1
  49. package/prompts/micro/task_lists_pattern.md +0 -1
  50. package/prompts/micro/temp_folder.md +0 -1
  51. package/prompts/micro/think_before_acting.md +0 -5
  52. package/prompts/micro/tone_and_style.md +0 -1
  53. package/prompts/micro/tool_preferences.md +0 -1
  54. package/prompts/micro/trust_subagent_context.md +0 -1
  55. package/prompts/micro/when_to_use_sub_agent.md +0 -1
  56. package/requirements.txt +0 -9
  57. package/src/__init__.py +0 -11
  58. package/src/core/__init__.py +0 -1
  59. package/src/core/agentic.py +0 -1085
  60. package/src/core/chat_manager.py +0 -1577
  61. package/src/core/config_manager.py +0 -260
  62. package/src/core/cron.py +0 -578
  63. package/src/core/cron_allowlist.py +0 -118
  64. package/src/core/memory.py +0 -145
  65. package/src/core/metadata.py +0 -75
  66. package/src/core/retry.py +0 -71
  67. package/src/core/skills.py +0 -463
  68. package/src/core/sub_agent.py +0 -376
  69. package/src/core/tool_approval.py +0 -220
  70. package/src/core/tool_feedback.py +0 -789
  71. package/src/exceptions.py +0 -79
  72. package/src/llm/__init__.py +0 -1
  73. package/src/llm/client.py +0 -176
  74. package/src/llm/codex_provider.py +0 -350
  75. package/src/llm/config.py +0 -536
  76. package/src/llm/prompts.py +0 -494
  77. package/src/llm/providers.py +0 -438
  78. package/src/llm/streaming.py +0 -163
  79. package/src/llm/token_tracker.py +0 -399
  80. package/src/tools/__init__.py +0 -151
  81. package/src/tools/constants.py +0 -59
  82. package/src/tools/create_file.py +0 -136
  83. package/src/tools/directory.py +0 -389
  84. package/src/tools/edit.py +0 -549
  85. package/src/tools/file_reader.py +0 -322
  86. package/src/tools/helpers/__init__.py +0 -99
  87. package/src/tools/helpers/base.py +0 -599
  88. package/src/tools/helpers/converters.py +0 -44
  89. package/src/tools/helpers/file_helpers.py +0 -189
  90. package/src/tools/helpers/formatters.py +0 -411
  91. package/src/tools/helpers/loader.py +0 -145
  92. package/src/tools/helpers/parallel_executor.py +0 -231
  93. package/src/tools/helpers/path_resolver.py +0 -283
  94. package/src/tools/helpers/plugin_manifest.py +0 -185
  95. package/src/tools/obsidian.py +0 -96
  96. package/src/tools/review_sub_agent.py +0 -190
  97. package/src/tools/rg_search.py +0 -477
  98. package/src/tools/search_plugins.py +0 -177
  99. package/src/tools/select_option.py +0 -600
  100. package/src/tools/shell.py +0 -302
  101. package/src/tools/sub_agent.py +0 -139
  102. package/src/tools/task_list.py +0 -269
  103. package/src/tools/web_search.py +0 -61
  104. package/src/ui/__init__.py +0 -1
  105. package/src/ui/banner.py +0 -87
  106. package/src/ui/commands.py +0 -3131
  107. package/src/ui/displays.py +0 -239
  108. package/src/ui/loader.py +0 -284
  109. package/src/ui/main.py +0 -643
  110. package/src/ui/prompt_utils.py +0 -113
  111. package/src/ui/setting_selector.py +0 -590
  112. package/src/ui/setup_wizard.py +0 -294
  113. package/src/ui/sub_agent_panel.py +0 -234
  114. package/src/ui/tool_confirmation.py +0 -226
  115. package/src/utils/__init__.py +0 -1
  116. package/src/utils/citation_parser.py +0 -199
  117. package/src/utils/editor.py +0 -207
  118. package/src/utils/gitignore_filter.py +0 -149
  119. package/src/utils/logger.py +0 -254
  120. package/src/utils/paths.py +0 -30
  121. package/src/utils/result_parsers.py +0 -108
  122. package/src/utils/safe_commands.py +0 -243
  123. package/src/utils/settings.py +0 -195
  124. package/src/utils/user_message_logger.py +0 -120
  125. package/src/utils/validation.py +0 -201
  126. package/src/utils/web_search.py +0 -173
@@ -1,438 +0,0 @@
1
- """Provider-specific request/response handlers.
2
-
3
- This module isolates provider-specific API quirks into handler classes.
4
- """
5
-
6
- import json
7
- from typing import Optional, Dict, Any, Iterator
8
- import requests
9
-
10
- from exceptions import LLMResponseError
11
- from .codex_provider import CodexResponsesHandler
12
-
13
-
14
- class OpenAIHandler:
15
- """Handler for OpenAI-compatible providers.
16
-
17
- Supports: OpenAI, OpenRouter, GLM, Gemini, Kimi, MiniMax
18
- """
19
-
20
- def build_headers(self, config: Dict[str, Any]) -> Dict[str, str]:
21
- """Build request headers."""
22
- headers = {"Content-Type": "application/json"}
23
- if config.get("type") == "api" and config.get("api_key"):
24
- headers["Authorization"] = f"Bearer {config['api_key']}"
25
- if "headers_extra" in config:
26
- headers.update(config["headers_extra"])
27
- return headers
28
-
29
- def build_payload(self, config: Dict[str, Any], messages: list,
30
- tools: Optional[list] = None, stream: bool = True) -> Dict[str, Any]:
31
- """Build request payload."""
32
- payload = {**config.get("payload", {}), "messages": messages, "stream": stream}
33
-
34
- # Ensure model is set from config if not in payload
35
- if "model" not in payload:
36
- model_name = config.get("api_model") or config.get("model")
37
- if model_name:
38
- payload["model"] = model_name
39
-
40
- # Add tools if provided (OpenAI format)
41
- if tools:
42
- payload["tools"] = tools
43
-
44
- # Set default parameters if not in config
45
- if "temperature" not in payload and config.get("allow_temperature", True):
46
- payload["temperature"] = config.get("default_temperature", 0.1)
47
- if "top_p" not in payload and config.get("allow_top_p", True):
48
- payload["top_p"] = config.get("default_top_p", 0.9)
49
-
50
- return payload
51
-
52
- def parse_response(self, response_json: Dict[str, Any]) -> Dict[str, Any]:
53
- """Parse non-streaming response (already in OpenAI format)."""
54
- return response_json
55
-
56
- def parse_stream(self, response: requests.Response) -> Iterator[Dict[str, Any]]:
57
- """Parse streaming response.
58
-
59
- Yields text chunks, and finally yields a dict with __usage__ key.
60
- """
61
- usage_data = None
62
-
63
- for line in response.iter_lines():
64
- if line:
65
- line = line.decode('utf-8')
66
-
67
- # Skip OpenRouter comments (start with ':')
68
- if line.startswith(':'):
69
- continue
70
-
71
- if line.startswith('data: '):
72
- data_str = line[6:]
73
- if data_str.strip() == '[DONE]':
74
- break
75
-
76
- try:
77
- data = json.loads(data_str)
78
-
79
- # Check for mid-stream errors
80
- if 'error' in data:
81
- error_msg = data.get('error', {}).get('message', 'Unknown streaming error')
82
- raise LLMResponseError(
83
- f"Streaming error: {error_msg}",
84
- details={"error_data": data.get('error')}
85
- )
86
-
87
- # Capture usage data if present (usually in final chunk)
88
- if 'usage' in data:
89
- usage_data = dict(data['usage'])
90
- # Promote top-level cost into usage dict (OpenRouter places it here)
91
- if 'cost' in data:
92
- usage_data['cost'] = data['cost']
93
-
94
- choices = data.get('choices', [])
95
- if choices:
96
- delta = choices[0].get('delta', {})
97
- content = delta.get('content')
98
- if content is not None:
99
- yield content
100
-
101
- except json.JSONDecodeError as e:
102
- raise LLMResponseError(
103
- f"Failed to decode streaming response",
104
- details={"original_error": str(e)}
105
- )
106
-
107
- # Yield usage data as final item if captured
108
- if usage_data:
109
- yield {'__usage__': usage_data}
110
-
111
-
112
- class AnthropicHandler:
113
- """Handler for Anthropic API.
114
-
115
- Anthropic has significant differences from OpenAI:
116
- - Different endpoint (/messages vs /chat/completions)
117
- - Different message format (content arrays vs strings)
118
- - Different tool format (flat vs nested)
119
- - Different streaming (SSE with event types vs data: lines)
120
- - Different headers (x-api-key vs Authorization: Bearer)
121
- - Different parameters (requires max_tokens, forbids top_p with temperature)
122
- """
123
-
124
- def build_headers(self, config: Dict[str, Any]) -> Dict[str, str]:
125
- """Build request headers (Anthropic uses x-api-key)."""
126
- headers = {"Content-Type": "application/json"}
127
- if config.get("type") == "api" and config.get("api_key"):
128
- headers["x-api-key"] = config['api_key']
129
- if "headers_extra" in config:
130
- headers.update(config["headers_extra"])
131
- return headers
132
-
133
- def build_payload(self, config: Dict[str, Any], messages: list,
134
- tools: Optional[list] = None, stream: bool = True) -> Dict[str, Any]:
135
- """Build request payload (Anthropic format)."""
136
- # Extract system messages to top-level parameter
137
- system_messages = [msg["content"] for msg in messages if msg.get("role") == "system"]
138
- system_content = "\n".join(system_messages) if system_messages else None
139
- non_system_messages = [msg for msg in messages if msg.get("role") != "system"]
140
-
141
- # Convert messages and tools to Anthropic format
142
- anthropic_messages = self._convert_messages_to_anthropic(non_system_messages)
143
- anthropic_tools = self._convert_tools_to_anthropic(tools) if tools else None
144
-
145
- payload = {**config.get("payload", {}), "messages": anthropic_messages, "stream": stream}
146
-
147
- # Ensure model is set from config if not in payload
148
- if "model" not in payload:
149
- model_name = config.get("api_model") or config.get("model")
150
- if model_name:
151
- payload["model"] = model_name
152
-
153
- if system_content:
154
- payload["system"] = system_content
155
- if anthropic_tools:
156
- payload["tools"] = anthropic_tools
157
-
158
- # Set default parameters (Anthropic requires max_tokens)
159
- if "temperature" not in payload and config.get("allow_temperature", True):
160
- payload["temperature"] = config.get("default_temperature", 0.1)
161
- if "max_tokens" not in payload:
162
- payload["max_tokens"] = config.get("max_tokens", 4096)
163
-
164
- # Anthropic doesn't allow both temperature and top_p
165
- # Only set top_p if temperature is not set
166
- if "temperature" not in payload and "top_p" not in payload:
167
- payload["top_p"] = config.get("default_top_p", 0.9)
168
-
169
- return payload
170
-
171
- def parse_response(self, response_json: Dict[str, Any]) -> Dict[str, Any]:
172
- """Convert Anthropic response format to OpenAI-style format."""
173
- # Anthropic format: {"content": [{"type": "text", "text": "..."}], "usage": {...}}
174
- # OpenAI format: {"choices": [{"message": {"content": "..."}}], "usage": {...}}
175
-
176
- # Convert Anthropic usage format (input_tokens/output_tokens) to OpenAI format (prompt_tokens/completion_tokens)
177
- # Anthropic's input_tokens does NOT include cache tokens; total input =
178
- # input_tokens + cache_read_input_tokens + cache_creation_input_tokens
179
- anthropic_usage = response_json.get("usage", {})
180
- cache_read = anthropic_usage.get('cache_read_input_tokens', 0)
181
- cache_creation = anthropic_usage.get('cache_creation_input_tokens', 0)
182
- prompt_tokens = anthropic_usage.get('input_tokens', 0) + cache_read + cache_creation
183
- completion_tokens = anthropic_usage.get('output_tokens', 0)
184
- openai_format_usage = {
185
- 'prompt_tokens': prompt_tokens,
186
- 'completion_tokens': completion_tokens,
187
- 'total_tokens': prompt_tokens + completion_tokens,
188
- }
189
- # Preserve Anthropic cache token fields for the token tracker
190
- if 'cache_read_input_tokens' in anthropic_usage:
191
- openai_format_usage['cache_read_input_tokens'] = anthropic_usage['cache_read_input_tokens']
192
- if 'cache_creation_input_tokens' in anthropic_usage:
193
- openai_format_usage['cache_creation_input_tokens'] = anthropic_usage['cache_creation_input_tokens']
194
- # Preserve non-cache input count so cost estimation can bill only the
195
- # non-cache portion without relying on fragile prompt_tokens subtraction.
196
- if 'input_tokens' in anthropic_usage:
197
- openai_format_usage['input_tokens'] = anthropic_usage['input_tokens']
198
-
199
- result = {
200
- "choices": [],
201
- "usage": openai_format_usage
202
- }
203
-
204
- # Extract content from Anthropic's content array
205
- content_blocks = response_json.get("content", [])
206
- text_parts = []
207
- tool_calls = []
208
-
209
- for block in content_blocks:
210
- if block.get("type") == "text":
211
- text_parts.append(block.get("text", ""))
212
- elif block.get("type") == "tool_use":
213
- # Convert Anthropic tool_use to OpenAI tool_calls format
214
- tool_calls.append({
215
- "id": block.get("id"),
216
- "type": "function",
217
- "function": {
218
- "name": block.get("name"),
219
- "arguments": json.dumps(block.get("input", {}))
220
- }
221
- })
222
-
223
- # Build OpenAI-style message
224
- message = {"role": "assistant"}
225
-
226
- # Include either text content or tool calls
227
- if tool_calls:
228
- message["content"] = None
229
- message["tool_calls"] = tool_calls
230
- else:
231
- message["content"] = "".join(text_parts)
232
-
233
- result["choices"].append({"message": message})
234
-
235
- return result
236
-
237
- def parse_stream(self, response: requests.Response) -> Iterator[Dict[str, Any]]:
238
- """Parse Anthropic's SSE-based streaming response.
239
-
240
- Yields text chunks, and finally yields a dict with __usage__ key.
241
-
242
- Anthropic splits usage across two events:
243
- - message_start: contains input_tokens
244
- - message_delta: contains output_tokens
245
- We merge both and convert to OpenAI format (prompt_tokens/completion_tokens).
246
- """
247
- usage_data = {}
248
-
249
- for line in response.iter_lines():
250
- if line:
251
- line = line.decode('utf-8')
252
-
253
- # Anthropic uses SSE format: "event: <type>" followed by "data: <json>"
254
- if line.startswith('data: '):
255
- data_str = line[6:]
256
- try:
257
- data = json.loads(data_str)
258
-
259
- # Check for errors
260
- if data.get('type') == 'error':
261
- error_msg = data.get('error', {}).get('message', 'Unknown error')
262
- raise LLMResponseError(
263
- f"Anthropic streaming error: {error_msg}",
264
- details={"error_data": data.get('error')}
265
- )
266
-
267
- # Capture input_tokens from message_start events
268
- if data.get('type') == 'message_start':
269
- message_usage = data.get('message', {}).get('usage', {})
270
- if message_usage:
271
- usage_data.update(message_usage)
272
-
273
- # Capture output_tokens from message_delta events
274
- if data.get('type') == 'message_delta' and 'usage' in data:
275
- usage_data.update(data['usage'])
276
-
277
- # Extract text from content_block_delta events
278
- if data.get('type') == 'content_block_delta':
279
- delta = data.get('delta', {})
280
- if delta.get('type') == 'text_delta':
281
- text = delta.get('text', '')
282
- if text:
283
- yield text
284
-
285
- except json.JSONDecodeError as e:
286
- raise LLMResponseError(
287
- f"Failed to decode Anthropic streaming response",
288
- details={"original_error": str(e)}
289
- )
290
-
291
- # Yield usage data as final item if captured
292
- # Convert Anthropic format (input_tokens/output_tokens) to OpenAI format (prompt_tokens/completion_tokens)
293
- # Anthropic's input_tokens does NOT include cache tokens; total input =
294
- # input_tokens + cache_read_input_tokens + cache_creation_input_tokens
295
- if usage_data:
296
- cache_read = usage_data.get('cache_read_input_tokens', 0)
297
- cache_creation = usage_data.get('cache_creation_input_tokens', 0)
298
- prompt_tokens = usage_data.get('input_tokens', 0) + cache_read + cache_creation
299
- completion_tokens = usage_data.get('output_tokens', 0)
300
- openai_format_usage = {
301
- 'prompt_tokens': prompt_tokens,
302
- 'completion_tokens': completion_tokens,
303
- 'total_tokens': prompt_tokens + completion_tokens,
304
- }
305
- # Preserve Anthropic cache token fields for the token tracker
306
- if 'cache_read_input_tokens' in usage_data:
307
- openai_format_usage['cache_read_input_tokens'] = usage_data['cache_read_input_tokens']
308
- if 'cache_creation_input_tokens' in usage_data:
309
- openai_format_usage['cache_creation_input_tokens'] = usage_data['cache_creation_input_tokens']
310
- # Preserve non-cache input count for accurate cost estimation
311
- if 'input_tokens' in usage_data:
312
- openai_format_usage['input_tokens'] = usage_data['input_tokens']
313
- yield {'__usage__': openai_format_usage}
314
-
315
- @staticmethod
316
- def _convert_tools_to_anthropic(openai_tools: list) -> list:
317
- """Convert OpenAI-style tool definitions to Anthropic format.
318
-
319
- OpenAI format: {"type": "function", "function": {"name": "...", "parameters": {...}}}
320
- Anthropic format: {"name": "...", "description": "...", "input_schema": {...}}
321
- """
322
- anthropic_tools = []
323
-
324
- for openai_tool in openai_tools:
325
- if openai_tool.get("type") == "function":
326
- func = openai_tool.get("function", {})
327
- anthropic_tool = {
328
- "name": func.get("name"),
329
- "description": func.get("description", ""),
330
- "input_schema": func.get("parameters", {"type": "object", "properties": {}})
331
- }
332
- anthropic_tools.append(anthropic_tool)
333
-
334
- return anthropic_tools
335
-
336
- @staticmethod
337
- def _convert_messages_to_anthropic(openai_messages: list) -> list:
338
- """Convert OpenAI-style messages to Anthropic format.
339
-
340
- Anthropic requires all content to be an array, not a string.
341
-
342
- OpenAI format:
343
- {"role": "user", "content": "text"}
344
- {"role": "tool", "content": "...", "tool_call_id": "..."}
345
-
346
- Anthropic format:
347
- {"role": "user", "content": [{"type": "text", "text": "..."}]}
348
- {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "...", "content": "..."}]}
349
- """
350
- anthropic_messages = []
351
-
352
- for msg in openai_messages:
353
- # Handle tool result messages
354
- if msg.get("role") == "tool":
355
- anthropic_msg = {
356
- "role": "user",
357
- "content": [
358
- {
359
- "type": "tool_result",
360
- "tool_use_id": msg.get("tool_call_id"),
361
- "content": msg.get("content", "")
362
- }
363
- ]
364
- }
365
- anthropic_messages.append(anthropic_msg)
366
- # Handle user and assistant messages - convert string content to array
367
- elif msg.get("role") in ("user", "assistant"):
368
- content = msg.get("content", "")
369
- tool_calls = msg.get("tool_calls")
370
-
371
- # Build content blocks array
372
- content_blocks = []
373
-
374
- # Add text content if present
375
- if isinstance(content, str) and content.strip():
376
- content_blocks.append({
377
- "type": "text",
378
- "text": content
379
- })
380
- elif isinstance(content, list):
381
- # Already an array (Anthropic format), use as-is
382
- anthropic_messages.append(msg)
383
- continue
384
-
385
- # Add tool_use blocks if present (for assistant messages with tool calls)
386
- if tool_calls:
387
- for tool_call in tool_calls:
388
- content_blocks.append({
389
- "type": "tool_use",
390
- "id": tool_call.get("id"),
391
- "name": tool_call.get("function", {}).get("name"),
392
- "input": json.loads(tool_call.get("function", {}).get("arguments", "{}"))
393
- })
394
-
395
- # Only add message if we have content blocks (text or tool_use)
396
- if content_blocks:
397
- anthropic_msg = {
398
- "role": msg.get("role"),
399
- "content": content_blocks
400
- }
401
- anthropic_messages.append(anthropic_msg)
402
- else:
403
- # Other message types, pass through
404
- anthropic_messages.append(msg)
405
-
406
- return anthropic_messages
407
-
408
-
409
- # Handler registry - maps provider names to handler classes
410
- HANDLER_REGISTRY = {
411
- "openai": OpenAIHandler,
412
- "openrouter": OpenAIHandler,
413
- "glm": OpenAIHandler,
414
- "glm_plan": OpenAIHandler,
415
- "gemini": OpenAIHandler,
416
- "minimax": AnthropicHandler,
417
- "minimax_plan": AnthropicHandler,
418
- "kimi": OpenAIHandler,
419
- "anthropic": AnthropicHandler,
420
- "local": OpenAIHandler,
421
- "codex": CodexResponsesHandler,
422
- }
423
-
424
-
425
- def get_handler(provider_name: str):
426
- """Get handler instance for the given provider.
427
-
428
- Args:
429
- provider_name: Name of the provider
430
-
431
- Returns:
432
- Handler instance for the provider
433
- """
434
- handler_class = HANDLER_REGISTRY.get(provider_name.lower(), OpenAIHandler)
435
- return handler_class()
436
-
437
-
438
- __all__ = ['OpenAIHandler', 'AnthropicHandler', 'CodexResponsesHandler', 'get_handler']
@@ -1,163 +0,0 @@
1
- """Streaming response assembler for agentic mode.
2
-
3
- Consumes a StreamWrapper yielding mixed delta dicts and assembles them into
4
- a complete message dict (content + tool_calls), matching the format that
5
- non-streaming responses already produce.
6
-
7
- Usage:
8
- stream = client.chat_completion(messages, stream=True, tools=tools)
9
- assembler = StreamingResponse(stream, console, debug_mode=False)
10
- message = assembler.consume() # iterates stream, prints text, assembles tool_calls
11
- tool_calls = message.get("tool_calls")
12
- usage = assembler.usage
13
- """
14
-
15
- import json
16
- import sys
17
- from typing import Any, Dict, List, Optional
18
-
19
- from rich.text import Text
20
-
21
-
22
- class StreamingResponse:
23
- """Assemble streaming deltas into a complete message dict.
24
-
25
- Text deltas are printed to stderr immediately (raw, no formatting).
26
- Tool call deltas are buffered and reassembled across chunks.
27
- """
28
-
29
- def __init__(self, stream, console=None, debug_mode: bool = False,
30
- on_text=None, live=None):
31
- """
32
- Args:
33
- stream: StreamWrapper (or any iterable yielding deltas / __usage__ dicts).
34
- console: Rich Console instance (used for debug logging only).
35
- debug_mode: If True, log assembly details.
36
- on_text: Optional callback(str) invoked for each text token.
37
- Defaults to printing to stderr.
38
- live: Optional Rich Live context. When set, streaming text is
39
- rendered through Live (raw during streaming, swappable to
40
- Markdown on completion) instead of raw stderr.
41
- """
42
- self._stream = stream
43
- self._console = console
44
- self._debug = debug_mode
45
- self._on_text = on_text
46
- self._live = live
47
-
48
- # Accumulated state
49
- self._text_parts: List[str] = []
50
- self._tool_calls: Dict[int, Dict[str, Any]] = {} # index -> partial tool call
51
- self._usage: Optional[Dict[str, Any]] = None
52
-
53
- def consume(self) -> Dict[str, Any]:
54
- """Iterate the stream, print text tokens, assemble tool calls.
55
-
56
- Returns:
57
- A message dict with 'role', 'content', and optionally 'tool_calls'
58
- — same shape as a non-streaming response["choices"][0]["message"].
59
- """
60
- for item in self._stream:
61
- if isinstance(item, dict) and '__usage__' in item:
62
- self._usage = item['__usage__']
63
- continue
64
-
65
- # OpenAI-style delta: {"content": "...", "tool_calls": [...]}
66
- if isinstance(item, dict):
67
- self._process_delta(item)
68
- elif isinstance(item, str):
69
- # Fallback: plain text string (legacy parse_stream behavior)
70
- self._print(item)
71
- self._text_parts.append(item)
72
-
73
- return self._build_message()
74
-
75
- @property
76
- def usage(self) -> Optional[Dict[str, Any]]:
77
- """Usage data captured from the stream's final chunk."""
78
- return self._usage
79
-
80
- def _process_delta(self, delta: Dict[str, Any]):
81
- """Process a single streaming delta dict.
82
-
83
- Expected shapes (OpenAI format):
84
- {"content": "some text"}
85
- {"tool_calls": [{"index": 0, "id": "call_xxx", "function": {"name": "f"}}]}
86
- {"tool_calls": [{"index": 0, "function": {"arguments": "{..."}}]}
87
- {"content": "text", "tool_calls": [...]}
88
- """
89
- # Handle text content
90
- content = delta.get("content")
91
- if content is not None:
92
- self._print(content)
93
- self._text_parts.append(content)
94
-
95
- # Handle tool call fragments
96
- tool_calls = delta.get("tool_calls")
97
- if tool_calls:
98
- for tc_delta in tool_calls:
99
- idx = tc_delta.get("index", 0)
100
- if idx not in self._tool_calls:
101
- self._tool_calls[idx] = {
102
- "id": "",
103
- "type": "function",
104
- "function": {"name": "", "arguments": ""},
105
- }
106
-
107
- entry = self._tool_calls[idx]
108
-
109
- # Tool call id (sent once at the start)
110
- if tc_delta.get("id"):
111
- entry["id"] = tc_delta["id"]
112
-
113
- # Function name (sent once at the start)
114
- func = tc_delta.get("function", {})
115
- if func.get("name"):
116
- entry["function"]["name"] = func["name"]
117
-
118
- # Arguments (sent incrementally, concatenated)
119
- if func.get("arguments"):
120
- entry["function"]["arguments"] += func["arguments"]
121
-
122
- def _build_message(self) -> Dict[str, Any]:
123
- """Build the final message dict from assembled parts."""
124
- message: Dict[str, Any] = {"role": "assistant"}
125
-
126
- # Collect assembled tool calls in index order
127
- assembled_tool_calls = []
128
- if self._tool_calls:
129
- for idx in sorted(self._tool_calls.keys()):
130
- assembled_tool_calls.append(self._tool_calls[idx])
131
-
132
- if assembled_tool_calls:
133
- message["tool_calls"] = assembled_tool_calls
134
- # Content may be None or a string alongside tool calls
135
- text = "".join(self._text_parts).strip()
136
- message["content"] = text if text else None
137
- else:
138
- message["content"] = "".join(self._text_parts)
139
-
140
- return message
141
-
142
- def _print(self, text: str):
143
- """Output text token via the configured callback (default: stderr).
144
-
145
- When a Rich Live context is provided, text is rendered through Live
146
- for atomic screen updates (raw text during streaming, swappable to
147
- Markdown on completion).
148
- """
149
- if self._live is not None:
150
- # Render through Rich Live — update with accumulated text so far
151
- self._live.update(Text("".join(self._text_parts) + text))
152
- elif self._on_text is None:
153
- # Default: print to stderr
154
- sys.stderr.write(text)
155
- sys.stderr.flush()
156
- elif callable(self._on_text):
157
- self._on_text(text)
158
- # If on_text is False, silently drop output (subagent mode)
159
-
160
- def close(self):
161
- """Close the underlying stream."""
162
- if hasattr(self._stream, 'close'):
163
- self._stream.close()