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.
Files changed (87) hide show
  1. package/README.md +19 -2
  2. package/config.yaml.example +13 -2
  3. package/package.json +3 -2
  4. package/prompts/main/ask_questions.md +31 -0
  5. package/prompts/main/batch_independent_calls.md +5 -0
  6. package/prompts/main/casual_interactions.md +11 -0
  7. package/prompts/main/code_references.md +8 -0
  8. package/prompts/main/communication_style.md +12 -0
  9. package/prompts/main/context_reliability.md +12 -0
  10. package/prompts/main/conversational_tool_calling.md +15 -0
  11. package/prompts/main/dream.md +50 -0
  12. package/prompts/main/editing_pattern.md +13 -0
  13. package/prompts/main/error_handling.md +6 -0
  14. package/prompts/main/exploration_pattern.md +21 -0
  15. package/prompts/main/intro.md +1 -0
  16. package/prompts/main/obsidian.md +16 -0
  17. package/prompts/main/obsidian_project.md +79 -0
  18. package/prompts/main/professional_objectivity.md +3 -0
  19. package/prompts/main/skills.md +3 -0
  20. package/prompts/main/targeted_searching.md +10 -0
  21. package/prompts/main/task_lists_pattern.md +8 -0
  22. package/prompts/main/temp_folder.md +9 -0
  23. package/prompts/main/think_before_acting.md +10 -0
  24. package/prompts/main/tone_and_style.md +4 -0
  25. package/prompts/main/tool_preferences.md +24 -0
  26. package/prompts/main/trust_subagent_context.md +21 -0
  27. package/prompts/main/when_to_use_sub_agent.md +7 -0
  28. package/prompts/micro/ask_questions.md +1 -0
  29. package/prompts/micro/batch_independent_calls.md +1 -0
  30. package/prompts/micro/casual_interactions.md +1 -0
  31. package/prompts/micro/code_references.md +1 -0
  32. package/prompts/micro/communication_style.md +1 -0
  33. package/prompts/micro/context_reliability.md +1 -0
  34. package/prompts/micro/conversational_tool_calling.md +1 -0
  35. package/prompts/micro/editing_pattern.md +1 -0
  36. package/prompts/micro/error_handling.md +1 -0
  37. package/prompts/micro/exploration_pattern.md +1 -0
  38. package/prompts/micro/intro.md +1 -0
  39. package/prompts/micro/obsidian.md +4 -0
  40. package/prompts/micro/obsidian_project.md +5 -0
  41. package/prompts/micro/professional_objectivity.md +1 -0
  42. package/prompts/micro/skills.md +1 -0
  43. package/prompts/micro/targeted_searching.md +1 -0
  44. package/prompts/micro/task_lists_pattern.md +1 -0
  45. package/prompts/micro/temp_folder.md +1 -0
  46. package/prompts/micro/think_before_acting.md +5 -0
  47. package/prompts/micro/tone_and_style.md +1 -0
  48. package/prompts/micro/tool_preferences.md +1 -0
  49. package/prompts/micro/trust_subagent_context.md +1 -0
  50. package/prompts/micro/when_to_use_sub_agent.md +1 -0
  51. package/src/core/agentic.py +134 -106
  52. package/src/core/chat_manager.py +60 -12
  53. package/src/core/config_manager.py +14 -1
  54. package/src/core/cron.py +57 -6
  55. package/src/core/memory.py +3 -90
  56. package/src/core/metadata.py +75 -0
  57. package/src/core/skills.py +463 -0
  58. package/src/core/sub_agent.py +93 -43
  59. package/src/core/tool_feedback.py +87 -76
  60. package/src/llm/client.py +7 -2
  61. package/src/llm/codex_provider.py +350 -0
  62. package/src/llm/config.py +74 -4
  63. package/src/llm/prompts.py +261 -502
  64. package/src/llm/providers.py +28 -7
  65. package/src/llm/token_tracker.py +32 -1
  66. package/src/tools/__init__.py +24 -85
  67. package/src/tools/create_file.py +1 -1
  68. package/src/tools/directory.py +1 -1
  69. package/src/tools/edit.py +13 -7
  70. package/src/tools/file_reader.py +1 -1
  71. package/src/tools/helpers/__init__.py +1 -7
  72. package/src/tools/helpers/base.py +65 -16
  73. package/src/tools/helpers/loader.py +2 -88
  74. package/src/tools/helpers/path_resolver.py +70 -13
  75. package/src/tools/helpers/plugin_manifest.py +99 -70
  76. package/src/tools/review_sub_agent.py +2 -1
  77. package/src/tools/rg_search.py +119 -35
  78. package/src/tools/search_plugins.py +140 -72
  79. package/src/tools/shell.py +3 -3
  80. package/src/ui/commands.py +470 -33
  81. package/src/ui/displays.py +27 -1
  82. package/src/ui/main.py +1 -4
  83. package/src/ui/tool_confirmation.py +16 -5
  84. package/src/utils/editor.py +88 -39
  85. package/src/utils/settings.py +25 -4
  86. package/src/utils/user_message_logger.py +120 -0
  87. 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 a tree of found plugins with query and category info,
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
- query_match = re.search(r"matching '([^']+)':", tool_result)
314
- if query_match:
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
- plugin_match = re.match(r'^- \*\*(.+?)\*\*(?:\s+\[(.+?)\])?\s+\((.+?)\):\s+(.+)$', lines[i])
323
- if plugin_match:
324
- name = plugin_match.group(1)
325
- category = plugin_match.group(2) or ""
326
- status = plugin_match.group(3)
327
- description = plugin_match.group(4)
328
-
329
- # Check next line for tags
330
- tags = ""
331
- if i + 1 < len(lines) and lines[i + 1].strip().startswith("Tags:"):
332
- tags = lines[i + 1].strip().replace("Tags: ", "")
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 plugins:
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 plugins found"
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
- # Build tree display
356
- max_display = 10
357
- display_plugins = plugins[:max_display]
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
- # Count activated vs already active
361
- activated_count = sum(1 for p in plugins if p["status"] == "activated")
362
- already_active_count = sum(1 for p in plugins if p["status"] == "already active")
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
- # Build the plugin line: name [category] (status)
370
- cat_part = f" [{plugin['category']}]" if plugin['category'] else ""
371
- status_part = plugin['status']
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 status_part == "activated":
374
- status_display = f"[green]{status_part}[/green]"
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
- status_display = f"[dim]{status_part}[/dim]"
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
- # Tags on sub-line
382
- if plugin["tags"]:
383
- tag_connector = "│ " if not is_last else " "
384
- tree_lines.append(f"{tag_connector} Tags: [dim]{plugin['tags']}[/dim]")
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
- # Build header
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
- response_json = response.json()
131
- return self.handler.parse_response(response_json)
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