bone-agent 1.3.2 → 1.4.0
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 +19 -2
- package/config.yaml.example +13 -2
- package/package.json +3 -2
- package/prompts/main/ask_questions.md +31 -0
- package/prompts/main/batch_independent_calls.md +5 -0
- package/prompts/main/casual_interactions.md +11 -0
- package/prompts/main/code_references.md +8 -0
- package/prompts/main/communication_style.md +12 -0
- package/prompts/main/context_reliability.md +12 -0
- package/prompts/main/conversational_tool_calling.md +15 -0
- package/prompts/main/dream.md +50 -0
- package/prompts/main/editing_pattern.md +13 -0
- package/prompts/main/error_handling.md +6 -0
- package/prompts/main/exploration_pattern.md +21 -0
- package/prompts/main/intro.md +1 -0
- package/prompts/main/obsidian.md +16 -0
- package/prompts/main/obsidian_project.md +79 -0
- package/prompts/main/professional_objectivity.md +3 -0
- package/prompts/main/skills.md +3 -0
- package/prompts/main/targeted_searching.md +10 -0
- package/prompts/main/task_lists_pattern.md +8 -0
- package/prompts/main/temp_folder.md +9 -0
- package/prompts/main/think_before_acting.md +10 -0
- package/prompts/main/tone_and_style.md +4 -0
- package/prompts/main/tool_preferences.md +24 -0
- package/prompts/main/trust_subagent_context.md +21 -0
- package/prompts/main/when_to_use_sub_agent.md +7 -0
- package/prompts/micro/ask_questions.md +1 -0
- package/prompts/micro/batch_independent_calls.md +1 -0
- package/prompts/micro/casual_interactions.md +1 -0
- package/prompts/micro/code_references.md +1 -0
- package/prompts/micro/communication_style.md +1 -0
- package/prompts/micro/context_reliability.md +1 -0
- package/prompts/micro/conversational_tool_calling.md +1 -0
- package/prompts/micro/editing_pattern.md +1 -0
- package/prompts/micro/error_handling.md +1 -0
- package/prompts/micro/exploration_pattern.md +1 -0
- package/prompts/micro/intro.md +1 -0
- package/prompts/micro/obsidian.md +4 -0
- package/prompts/micro/obsidian_project.md +5 -0
- package/prompts/micro/professional_objectivity.md +1 -0
- package/prompts/micro/skills.md +1 -0
- package/prompts/micro/targeted_searching.md +1 -0
- package/prompts/micro/task_lists_pattern.md +1 -0
- package/prompts/micro/temp_folder.md +1 -0
- package/prompts/micro/think_before_acting.md +5 -0
- package/prompts/micro/tone_and_style.md +1 -0
- package/prompts/micro/tool_preferences.md +1 -0
- package/prompts/micro/trust_subagent_context.md +1 -0
- package/prompts/micro/when_to_use_sub_agent.md +1 -0
- package/src/core/agentic.py +134 -106
- package/src/core/chat_manager.py +60 -12
- package/src/core/config_manager.py +14 -1
- package/src/core/cron.py +57 -6
- package/src/core/memory.py +3 -90
- package/src/core/metadata.py +75 -0
- package/src/core/skills.py +463 -0
- package/src/core/sub_agent.py +93 -43
- package/src/core/tool_feedback.py +87 -76
- package/src/llm/client.py +7 -2
- package/src/llm/codex_provider.py +350 -0
- package/src/llm/config.py +74 -4
- package/src/llm/prompts.py +261 -502
- package/src/llm/providers.py +28 -7
- package/src/llm/token_tracker.py +32 -1
- package/src/tools/__init__.py +24 -85
- package/src/tools/create_file.py +1 -1
- package/src/tools/directory.py +1 -1
- package/src/tools/edit.py +13 -7
- package/src/tools/file_reader.py +1 -1
- package/src/tools/helpers/__init__.py +1 -7
- package/src/tools/helpers/base.py +65 -16
- package/src/tools/helpers/loader.py +2 -88
- package/src/tools/helpers/path_resolver.py +70 -13
- package/src/tools/helpers/plugin_manifest.py +99 -70
- package/src/tools/review_sub_agent.py +2 -1
- package/src/tools/rg_search.py +119 -35
- package/src/tools/search_plugins.py +140 -72
- package/src/tools/shell.py +3 -3
- package/src/ui/commands.py +470 -33
- package/src/ui/displays.py +27 -1
- package/src/ui/main.py +1 -4
- package/src/ui/tool_confirmation.py +16 -5
- package/src/utils/editor.py +88 -39
- package/src/utils/settings.py +25 -4
- package/src/utils/user_message_logger.py +120 -0
- package/src/utils/validation.py +10 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Tool result display functions for the agentic loop."""
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
|
+
import textwrap
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import Optional
|
|
6
7
|
|
|
@@ -8,6 +9,7 @@ from rich.syntax import Syntax
|
|
|
8
9
|
|
|
9
10
|
from utils.settings import MAX_COMMAND_OUTPUT_LINES, MonokaiDarkBGStyle
|
|
10
11
|
from utils.result_parsers import extract_exit_code, extract_all_metadata, extract_multiple_metadata
|
|
12
|
+
from tools.search_plugins import HEADER_MATCHES, HEADER_ALL
|
|
11
13
|
from tools.task_list import _format_task_list, _strip_rich_markup
|
|
12
14
|
|
|
13
15
|
|
|
@@ -303,110 +305,119 @@ def handle_list_directory_feedback(tool_result, console, panel_updater):
|
|
|
303
305
|
def handle_search_plugins_feedback(tool_result, console, panel_updater):
|
|
304
306
|
"""Handle feedback for search_plugins tool.
|
|
305
307
|
|
|
306
|
-
Display
|
|
307
|
-
similar to list_directory feedback style.
|
|
308
|
+
Display compact capability results in the same style as other tool output.
|
|
308
309
|
"""
|
|
309
310
|
lines = tool_result.split('\n')
|
|
311
|
+
prefix = "╰─ " if not panel_updater else ""
|
|
310
312
|
|
|
311
|
-
# Extract query from "Found N plugin(s) matching 'query':" line
|
|
312
313
|
query = ""
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
query = query_match.group(1)
|
|
314
|
+
capabilities = []
|
|
315
|
+
in_capability_section = False
|
|
316
316
|
|
|
317
|
-
# Parse plugin entries: - **name** [category] (status): description
|
|
318
|
-
# Tags: tag1, tag2
|
|
319
|
-
plugins = []
|
|
320
317
|
i = 0
|
|
321
318
|
while i < len(lines):
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
319
|
+
raw_line = lines[i]
|
|
320
|
+
line = raw_line.strip()
|
|
321
|
+
|
|
322
|
+
if line.startswith(HEADER_MATCHES):
|
|
323
|
+
query = line.replace(HEADER_MATCHES, "", 1)
|
|
324
|
+
in_capability_section = True
|
|
325
|
+
i += 1
|
|
326
|
+
continue
|
|
327
|
+
|
|
328
|
+
if line == HEADER_ALL:
|
|
329
|
+
query = line
|
|
330
|
+
in_capability_section = True
|
|
331
|
+
i += 1
|
|
332
|
+
continue
|
|
333
|
+
|
|
334
|
+
if not in_capability_section:
|
|
335
|
+
i += 1
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
if line.startswith("Results: ") or line.startswith("Total: ") or not line:
|
|
339
|
+
i += 1
|
|
340
|
+
continue
|
|
341
|
+
|
|
342
|
+
if line.startswith("Activated plugins:") or line.startswith("Loaded skills:") or line.startswith("Load issues:"):
|
|
343
|
+
break
|
|
344
|
+
|
|
345
|
+
item_match = re.match(r'^- (.+)$', line)
|
|
346
|
+
if item_match:
|
|
347
|
+
item = {
|
|
348
|
+
"name": item_match.group(1),
|
|
349
|
+
"type": "",
|
|
350
|
+
"status": "",
|
|
351
|
+
"summary": "",
|
|
352
|
+
"tags": "",
|
|
353
|
+
}
|
|
354
|
+
i += 1
|
|
355
|
+
while i < len(lines):
|
|
356
|
+
raw_detail_line = lines[i]
|
|
357
|
+
stripped = raw_detail_line.strip()
|
|
358
|
+
if not stripped:
|
|
359
|
+
i += 1
|
|
360
|
+
continue
|
|
361
|
+
if re.match(r'^- (.+)$', stripped):
|
|
362
|
+
break
|
|
363
|
+
if stripped.startswith("Activated plugins:") or stripped.startswith("Loaded skills:") or stripped.startswith("Load issues:"):
|
|
364
|
+
break
|
|
365
|
+
if stripped.startswith("type: "):
|
|
366
|
+
item["type"] = stripped.replace("type: ", "", 1)
|
|
367
|
+
elif stripped.startswith("status: "):
|
|
368
|
+
item["status"] = stripped.replace("status: ", "", 1)
|
|
369
|
+
elif stripped.startswith("summary: "):
|
|
370
|
+
item["summary"] = stripped.replace("summary: ", "", 1)
|
|
371
|
+
elif stripped.startswith("tags: "):
|
|
372
|
+
item["tags"] = stripped.replace("tags: ", "", 1)
|
|
333
373
|
i += 1
|
|
374
|
+
capabilities.append(item)
|
|
375
|
+
continue
|
|
334
376
|
|
|
335
|
-
plugins.append({
|
|
336
|
-
"name": name,
|
|
337
|
-
"category": category,
|
|
338
|
-
"status": status,
|
|
339
|
-
"description": description,
|
|
340
|
-
"tags": tags,
|
|
341
|
-
})
|
|
342
377
|
i += 1
|
|
343
378
|
|
|
344
|
-
if not
|
|
345
|
-
# No plugins found — just show the message
|
|
346
|
-
prefix = "╰─ " if not panel_updater else ""
|
|
347
|
-
# Extract the informational message (skip exit_code line)
|
|
379
|
+
if not capabilities:
|
|
348
380
|
msg_lines = [l for l in lines if l.strip() and not l.startswith("exit_code=")]
|
|
349
|
-
output = prefix + "\n".join(msg_lines) if msg_lines else f"{prefix}No
|
|
381
|
+
output = prefix + "\n".join(msg_lines) if msg_lines else f"{prefix}No capability matches"
|
|
350
382
|
_print_or_append(output, console, panel_updater)
|
|
351
383
|
if not panel_updater:
|
|
352
384
|
console.print()
|
|
353
385
|
return
|
|
354
386
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
remaining = max(0, len(plugins) - max_display)
|
|
387
|
+
def append_wrapped_detail(line_parts, text, indent, width=72):
|
|
388
|
+
wrapped_lines = textwrap.wrap(text, width=width) or [text]
|
|
389
|
+
line_parts.extend(f"{indent}[dim]{wrapped}[/dim]" for wrapped in wrapped_lines)
|
|
359
390
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
391
|
+
max_display = 10
|
|
392
|
+
display_capabilities = capabilities[:max_display]
|
|
393
|
+
remaining = max(0, len(capabilities) - max_display)
|
|
363
394
|
|
|
395
|
+
header = f"{query} ({len(capabilities)} match{'es' if len(capabilities) != 1 else ''})" if query else "capabilities"
|
|
364
396
|
tree_lines = []
|
|
365
|
-
for i, plugin in enumerate(display_plugins):
|
|
366
|
-
is_last = (i == len(display_plugins) - 1) and (remaining == 0)
|
|
367
|
-
connector = "└─" if is_last else "├─"
|
|
368
397
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
398
|
+
for idx, capability in enumerate(display_capabilities):
|
|
399
|
+
is_last = (idx == len(display_capabilities) - 1) and (remaining == 0)
|
|
400
|
+
connector = "└─" if is_last else "├─"
|
|
372
401
|
|
|
373
|
-
if
|
|
374
|
-
|
|
402
|
+
if capability["type"] == "plugin":
|
|
403
|
+
meta = "[dim](plugin)[/dim]"
|
|
404
|
+
if capability["status"]:
|
|
405
|
+
meta += f" [dim][{capability['status']}][/dim]"
|
|
375
406
|
else:
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
line = f" {connector} **{plugin['name']}**{cat_part} ({status_display}): {plugin['description']}"
|
|
379
|
-
tree_lines.append(line)
|
|
407
|
+
meta = "[dim](skill)[/dim]"
|
|
380
408
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
409
|
+
detail_indent = " │ " if not is_last else " "
|
|
410
|
+
line_parts = [f" {connector} {capability['name']} {meta}"]
|
|
411
|
+
if capability["summary"]:
|
|
412
|
+
append_wrapped_detail(line_parts, capability["summary"], detail_indent)
|
|
413
|
+
if capability["tags"]:
|
|
414
|
+
append_wrapped_detail(line_parts, f"tags: {capability['tags']}", detail_indent)
|
|
415
|
+
tree_lines.append("\n".join(line_parts))
|
|
385
416
|
|
|
386
|
-
# Add overflow indicator
|
|
387
417
|
if remaining > 0:
|
|
388
418
|
tree_lines.append(f" └─ ... and {remaining} more")
|
|
389
419
|
|
|
390
|
-
|
|
391
|
-
if query:
|
|
392
|
-
header = f"plugins matching '{query}' ({len(plugins)} found)"
|
|
393
|
-
else:
|
|
394
|
-
header = f"plugins ({len(plugins)} found)"
|
|
395
|
-
|
|
396
|
-
prefix = "╰─ " if not panel_updater else ""
|
|
397
|
-
output = f"{prefix}{header}\n"
|
|
398
|
-
output += "\n".join(tree_lines)
|
|
399
|
-
|
|
400
|
-
# Activation summary
|
|
401
|
-
if activated_count > 0 or already_active_count > 0:
|
|
402
|
-
summary_parts = []
|
|
403
|
-
if activated_count > 0:
|
|
404
|
-
summary_parts.append(f"{activated_count} activated")
|
|
405
|
-
if already_active_count > 0:
|
|
406
|
-
summary_parts.append(f"{already_active_count} already active")
|
|
407
|
-
summary = ", ".join(summary_parts)
|
|
408
|
-
output += f"\n{prefix}[dim]{summary}. Schemas available next turn. Auto-evict after 10 turns of non-use.[/dim]"
|
|
409
|
-
|
|
420
|
+
output = f"{prefix}{header}\n" + "\n".join(tree_lines)
|
|
410
421
|
_print_or_append(output, console, panel_updater)
|
|
411
422
|
|
|
412
423
|
if not panel_updater:
|
package/src/llm/client.py
CHANGED
|
@@ -127,8 +127,13 @@ class LLMClient:
|
|
|
127
127
|
self.handler.parse_stream(response)
|
|
128
128
|
)
|
|
129
129
|
else:
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
try:
|
|
131
|
+
response_json = response.json()
|
|
132
|
+
return self.handler.parse_response(response_json)
|
|
133
|
+
except ValueError:
|
|
134
|
+
if getattr(self.handler, "supports_sse_response_fallback", False):
|
|
135
|
+
return self.handler.parse_sse_response(response.text)
|
|
136
|
+
raise
|
|
132
137
|
|
|
133
138
|
except requests.exceptions.RequestException as e:
|
|
134
139
|
raise LLMConnectionError(
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Codex provider adapter.
|
|
2
|
+
|
|
3
|
+
Codex is intentionally isolated from the normal provider handlers because it is
|
|
4
|
+
not a Chat Completions-compatible API. It targets the ChatGPT Codex Responses
|
|
5
|
+
backend and adapts that protocol back into vmCode's OpenAI-style internal shape.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import copy
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any, Dict, Iterator, Optional
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
from exceptions import LLMResponseError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CodexResponsesHandler:
|
|
19
|
+
"""Adapter for the ChatGPT Codex Responses backend.
|
|
20
|
+
|
|
21
|
+
Codex-specific behavior kept here:
|
|
22
|
+
- Uses `instructions` + `input` instead of Chat Completions `messages`.
|
|
23
|
+
- Always sends `stream: true`; the backend returns SSE even for logical
|
|
24
|
+
non-streaming agent calls.
|
|
25
|
+
- Stores `_responses_output` replay metadata so tool-call turns can be sent
|
|
26
|
+
back in Responses-native form while using `store: false`.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
supports_sse_response_fallback = True
|
|
30
|
+
|
|
31
|
+
def build_headers(self, config: Dict[str, Any]) -> Dict[str, str]:
|
|
32
|
+
"""Build request headers."""
|
|
33
|
+
headers = {"Content-Type": "application/json"}
|
|
34
|
+
if config.get("type") == "api" and config.get("api_key"):
|
|
35
|
+
headers["Authorization"] = f"Bearer {config['api_key']}"
|
|
36
|
+
if "headers_extra" in config:
|
|
37
|
+
headers.update(config["headers_extra"])
|
|
38
|
+
return headers
|
|
39
|
+
|
|
40
|
+
def build_payload(self, config: Dict[str, Any], messages: list,
|
|
41
|
+
tools: Optional[list] = None, stream: bool = True) -> Dict[str, Any]:
|
|
42
|
+
"""Build request payload for Codex backend Responses API."""
|
|
43
|
+
system_parts = [m["content"] for m in messages if m.get("role") == "system"]
|
|
44
|
+
instructions = "\n".join(system_parts) if system_parts else "You are a helpful assistant."
|
|
45
|
+
|
|
46
|
+
codex_input = []
|
|
47
|
+
for m in messages:
|
|
48
|
+
if m.get("role") == "system":
|
|
49
|
+
continue
|
|
50
|
+
role = m.get("role", "user")
|
|
51
|
+
content = m.get("content", "")
|
|
52
|
+
|
|
53
|
+
if role == "assistant" and m.get("_responses_output"):
|
|
54
|
+
codex_input.extend(m.get("_responses_output") or [])
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
if role == "assistant" and m.get("tool_calls"):
|
|
58
|
+
if content:
|
|
59
|
+
codex_input.append({
|
|
60
|
+
"role": "assistant",
|
|
61
|
+
"content": [{"type": "input_text", "text": content}]
|
|
62
|
+
})
|
|
63
|
+
for tool_call in m.get("tool_calls", []):
|
|
64
|
+
function = tool_call.get("function", {})
|
|
65
|
+
codex_input.append({
|
|
66
|
+
"type": "function_call",
|
|
67
|
+
"call_id": tool_call.get("id"),
|
|
68
|
+
"name": function.get("name", ""),
|
|
69
|
+
"arguments": function.get("arguments", "{}"),
|
|
70
|
+
})
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
if role == "tool":
|
|
74
|
+
codex_input.append({
|
|
75
|
+
"type": "function_call_output",
|
|
76
|
+
"call_id": m.get("tool_call_id"),
|
|
77
|
+
"output": content,
|
|
78
|
+
})
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
content_type = "output_text" if role == "assistant" else "input_text"
|
|
82
|
+
codex_input.append({
|
|
83
|
+
"role": role,
|
|
84
|
+
"content": [{"type": content_type, "text": content}]
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
payload = {
|
|
88
|
+
**config.get("payload", {}),
|
|
89
|
+
"instructions": instructions,
|
|
90
|
+
"input": codex_input,
|
|
91
|
+
"store": False,
|
|
92
|
+
"stream": True,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if "model" not in payload:
|
|
96
|
+
model_name = config.get("api_model") or config.get("model")
|
|
97
|
+
if model_name:
|
|
98
|
+
payload["model"] = model_name
|
|
99
|
+
|
|
100
|
+
if tools:
|
|
101
|
+
payload["tools"] = [self._convert_tool_to_responses(tool) for tool in tools]
|
|
102
|
+
|
|
103
|
+
if "prompt_cache_key" not in payload:
|
|
104
|
+
model = payload.get("model") or "unknown-model"
|
|
105
|
+
payload["prompt_cache_key"] = self._build_prompt_cache_key(
|
|
106
|
+
model=model,
|
|
107
|
+
instructions=instructions,
|
|
108
|
+
tools=payload.get("tools"),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if "temperature" not in payload and config.get("allow_temperature", True):
|
|
112
|
+
payload["temperature"] = config.get("default_temperature", 0.1)
|
|
113
|
+
if "top_p" not in payload and config.get("allow_top_p", True):
|
|
114
|
+
payload["top_p"] = config.get("default_top_p", 0.9)
|
|
115
|
+
|
|
116
|
+
return payload
|
|
117
|
+
|
|
118
|
+
def _build_prompt_cache_key(
|
|
119
|
+
self,
|
|
120
|
+
*,
|
|
121
|
+
model: str,
|
|
122
|
+
instructions: str,
|
|
123
|
+
tools: Optional[list] = None,
|
|
124
|
+
) -> str:
|
|
125
|
+
"""Build a stable prompt-cache key for the reusable Codex prefix."""
|
|
126
|
+
cache_scope = {
|
|
127
|
+
"model": model,
|
|
128
|
+
"instructions": instructions,
|
|
129
|
+
"tools": tools or [],
|
|
130
|
+
}
|
|
131
|
+
canonical = json.dumps(
|
|
132
|
+
cache_scope,
|
|
133
|
+
sort_keys=True,
|
|
134
|
+
separators=(",", ":"),
|
|
135
|
+
ensure_ascii=True,
|
|
136
|
+
)
|
|
137
|
+
cache_hash = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:24]
|
|
138
|
+
return f"bone-agent:{cache_hash}"
|
|
139
|
+
|
|
140
|
+
def parse_response(self, response_json: Dict[str, Any]) -> Dict[str, Any]:
|
|
141
|
+
"""Parse Responses API output into Chat Completions format."""
|
|
142
|
+
return self._normalize_response(response_json)
|
|
143
|
+
|
|
144
|
+
def parse_sse_response(self, response_text: str) -> Dict[str, Any]:
|
|
145
|
+
"""Parse a full SSE response body into Chat Completions format."""
|
|
146
|
+
completed_response = None
|
|
147
|
+
output_items = []
|
|
148
|
+
|
|
149
|
+
for raw_line in response_text.splitlines():
|
|
150
|
+
line = raw_line.strip()
|
|
151
|
+
if not line.startswith("data: "):
|
|
152
|
+
continue
|
|
153
|
+
data_str = line[6:]
|
|
154
|
+
if data_str == "[DONE]":
|
|
155
|
+
break
|
|
156
|
+
try:
|
|
157
|
+
data = json.loads(data_str)
|
|
158
|
+
except json.JSONDecodeError as e:
|
|
159
|
+
raise LLMResponseError(
|
|
160
|
+
"Failed to decode SSE response from Codex backend",
|
|
161
|
+
details={"original_error": str(e)}
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if data.get("type") == "response.output_item.done":
|
|
165
|
+
item = data.get("item")
|
|
166
|
+
if item:
|
|
167
|
+
output_items.append(item)
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
if data.get("type") == "response.completed":
|
|
171
|
+
completed_response = data.get("response")
|
|
172
|
+
break
|
|
173
|
+
|
|
174
|
+
if completed_response is None:
|
|
175
|
+
raise LLMResponseError(
|
|
176
|
+
"Codex backend returned streaming data without a completed response event"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if not completed_response.get("output") and output_items:
|
|
180
|
+
completed_response = dict(completed_response)
|
|
181
|
+
completed_response["output"] = output_items
|
|
182
|
+
|
|
183
|
+
return self._normalize_response(completed_response)
|
|
184
|
+
|
|
185
|
+
def parse_stream(self, response: requests.Response) -> Iterator[Dict[str, Any]]:
|
|
186
|
+
"""Parse streaming Responses API."""
|
|
187
|
+
usage_data = None
|
|
188
|
+
|
|
189
|
+
for line in response.iter_lines():
|
|
190
|
+
if line:
|
|
191
|
+
line = line.decode('utf-8')
|
|
192
|
+
|
|
193
|
+
if line.startswith('data: '):
|
|
194
|
+
data_str = line[6:]
|
|
195
|
+
if data_str.strip() == '[DONE]':
|
|
196
|
+
break
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
data = json.loads(data_str)
|
|
200
|
+
|
|
201
|
+
if 'error' in data:
|
|
202
|
+
error_msg = data.get('error', {}).get('message', 'Unknown streaming error')
|
|
203
|
+
raise LLMResponseError(
|
|
204
|
+
f"Streaming error: {error_msg}",
|
|
205
|
+
details={"error_data": data.get('error')}
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
event_type = data.get("type", "")
|
|
209
|
+
|
|
210
|
+
if event_type == "response.completed":
|
|
211
|
+
resp = data.get("response", {})
|
|
212
|
+
if "usage" in resp:
|
|
213
|
+
usage_data = self._normalize_usage(resp["usage"])
|
|
214
|
+
|
|
215
|
+
if event_type == "response.output_text.delta":
|
|
216
|
+
delta = data.get("delta", "")
|
|
217
|
+
if delta:
|
|
218
|
+
yield delta
|
|
219
|
+
|
|
220
|
+
except json.JSONDecodeError as e:
|
|
221
|
+
raise LLMResponseError(
|
|
222
|
+
f"Failed to decode streaming response",
|
|
223
|
+
details={"original_error": str(e)}
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if usage_data:
|
|
227
|
+
yield {'__usage__': usage_data}
|
|
228
|
+
|
|
229
|
+
def _convert_tool_to_responses(self, tool: Dict[str, Any]) -> Dict[str, Any]:
|
|
230
|
+
"""Convert Chat Completions tool schema to Responses/Codex schema."""
|
|
231
|
+
if tool.get("type") == "function" and "function" in tool:
|
|
232
|
+
function = tool["function"]
|
|
233
|
+
return {
|
|
234
|
+
"type": "function",
|
|
235
|
+
"name": function.get("name", ""),
|
|
236
|
+
"description": function.get("description", ""),
|
|
237
|
+
"parameters": self._normalize_json_schema(function.get("parameters", {})),
|
|
238
|
+
"strict": False,
|
|
239
|
+
}
|
|
240
|
+
return tool
|
|
241
|
+
|
|
242
|
+
def _normalize_response(self, response_json: Dict[str, Any]) -> Dict[str, Any]:
|
|
243
|
+
"""Normalize Responses output into Chat Completions message shape."""
|
|
244
|
+
raw_usage = response_json.get("usage", {})
|
|
245
|
+
usage = self._normalize_usage(raw_usage)
|
|
246
|
+
|
|
247
|
+
output_items = response_json.get("output", [])
|
|
248
|
+
content_parts = []
|
|
249
|
+
tool_calls = []
|
|
250
|
+
|
|
251
|
+
for item in output_items:
|
|
252
|
+
item_type = item.get("type")
|
|
253
|
+
|
|
254
|
+
if item_type == "function_call":
|
|
255
|
+
call_id = item.get("call_id") or item.get("id")
|
|
256
|
+
tool_calls.append({
|
|
257
|
+
"id": call_id,
|
|
258
|
+
"type": "function",
|
|
259
|
+
"function": {
|
|
260
|
+
"name": item.get("name", ""),
|
|
261
|
+
"arguments": item.get("arguments", "{}"),
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
if item_type != "message":
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
for c in item.get("content", []):
|
|
270
|
+
if c.get("type") in {"output_text", "text"}:
|
|
271
|
+
text = c.get("text")
|
|
272
|
+
if text is not None:
|
|
273
|
+
content_parts.append(text)
|
|
274
|
+
|
|
275
|
+
message = {"role": "assistant"}
|
|
276
|
+
text_content = "\n".join(content_parts) if content_parts else ""
|
|
277
|
+
if tool_calls:
|
|
278
|
+
message["tool_calls"] = tool_calls
|
|
279
|
+
message["content"] = text_content or None
|
|
280
|
+
else:
|
|
281
|
+
message["content"] = text_content
|
|
282
|
+
|
|
283
|
+
replay_items = copy.deepcopy(output_items)
|
|
284
|
+
for item in replay_items:
|
|
285
|
+
item.pop("id", None)
|
|
286
|
+
message["_responses_output"] = replay_items
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
"choices": [{
|
|
290
|
+
"message": message,
|
|
291
|
+
"finish_reason": "tool_calls" if tool_calls else "stop",
|
|
292
|
+
}],
|
|
293
|
+
"usage": usage,
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
def _normalize_usage(self, usage: Any) -> Dict[str, Any]:
|
|
297
|
+
"""Normalize Codex Responses usage into vmCode's OpenAI-style usage shape."""
|
|
298
|
+
if not isinstance(usage, dict):
|
|
299
|
+
return {}
|
|
300
|
+
|
|
301
|
+
normalized = dict(usage)
|
|
302
|
+
|
|
303
|
+
input_tokens = normalized.get("input_tokens")
|
|
304
|
+
output_tokens = normalized.get("output_tokens")
|
|
305
|
+
|
|
306
|
+
if normalized.get("prompt_tokens") is None and input_tokens is not None:
|
|
307
|
+
normalized["prompt_tokens"] = input_tokens
|
|
308
|
+
if normalized.get("completion_tokens") is None and output_tokens is not None:
|
|
309
|
+
normalized["completion_tokens"] = output_tokens
|
|
310
|
+
if normalized.get("total_tokens") is None:
|
|
311
|
+
prompt_tokens = normalized.get("prompt_tokens")
|
|
312
|
+
completion_tokens = normalized.get("completion_tokens")
|
|
313
|
+
if prompt_tokens is not None and completion_tokens is not None:
|
|
314
|
+
normalized["total_tokens"] = prompt_tokens + completion_tokens
|
|
315
|
+
|
|
316
|
+
input_details = normalized.get("input_tokens_details")
|
|
317
|
+
if isinstance(input_details, dict) and input_details.get("cached_tokens") is not None:
|
|
318
|
+
cached_tokens = input_details["cached_tokens"]
|
|
319
|
+
if normalized.get("prompt_tokens_details") is None:
|
|
320
|
+
normalized["prompt_tokens_details"] = {"cached_tokens": cached_tokens}
|
|
321
|
+
elif isinstance(normalized["prompt_tokens_details"], dict):
|
|
322
|
+
normalized["prompt_tokens_details"].setdefault("cached_tokens", cached_tokens)
|
|
323
|
+
normalized.setdefault("cached_tokens", cached_tokens)
|
|
324
|
+
|
|
325
|
+
return normalized
|
|
326
|
+
|
|
327
|
+
def _normalize_json_schema(self, schema: Any) -> Any:
|
|
328
|
+
"""Normalize JSON Schema for strict Responses function tools."""
|
|
329
|
+
if not isinstance(schema, dict):
|
|
330
|
+
return schema
|
|
331
|
+
|
|
332
|
+
normalized = dict(schema)
|
|
333
|
+
schema_type = normalized.get("type")
|
|
334
|
+
|
|
335
|
+
if schema_type == "object":
|
|
336
|
+
properties = normalized.get("properties", {})
|
|
337
|
+
normalized["properties"] = {
|
|
338
|
+
key: self._normalize_json_schema(value)
|
|
339
|
+
for key, value in properties.items()
|
|
340
|
+
}
|
|
341
|
+
normalized.setdefault("additionalProperties", False)
|
|
342
|
+
|
|
343
|
+
if schema_type == "array" and "items" in normalized:
|
|
344
|
+
normalized["items"] = self._normalize_json_schema(normalized["items"])
|
|
345
|
+
|
|
346
|
+
for key in ("anyOf", "oneOf", "allOf"):
|
|
347
|
+
if key in normalized and isinstance(normalized[key], list):
|
|
348
|
+
normalized[key] = [self._normalize_json_schema(item) for item in normalized[key]]
|
|
349
|
+
|
|
350
|
+
return normalized
|