aicortexpy 0.1.0__py3-none-any.whl
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.
- aicortexpy/__init__.py +3 -0
- aicortexpy/__main__.py +5 -0
- aicortexpy/acp_client.py +559 -0
- aicortexpy/acp_conn.py +853 -0
- aicortexpy/acp_errors.py +107 -0
- aicortexpy/agent_env.py +88 -0
- aicortexpy/artifact_ws.py +233 -0
- aicortexpy/artifacts.py +67 -0
- aicortexpy/attachments.py +85 -0
- aicortexpy/auto_commit.py +106 -0
- aicortexpy/capabilities.py +92 -0
- aicortexpy/chat_acp_pool.py +321 -0
- aicortexpy/cli.py +133 -0
- aicortexpy/client.py +438 -0
- aicortexpy/codex_config.py +98 -0
- aicortexpy/config.py +298 -0
- aicortexpy/daemon.py +677 -0
- aicortexpy/data/default_skills/agent-memory/SKILL.md +67 -0
- aicortexpy/data/default_skills/aicorkit-atlassian/SKILL.md +144 -0
- aicortexpy/data/default_skills/aicorkit-graph/SKILL.md +145 -0
- aicortexpy/data/default_skills/aicorkit-outlook/SKILL.md +87 -0
- aicortexpy/data/default_skills/aicorkit-wxmsg/SKILL.md +96 -0
- aicortexpy/data/default_skills/aicortex-basics/SKILL.md +10 -0
- aicortexpy/data/default_skills/aicortex-bridge-cdp/SKILL.md +118 -0
- aicortexpy/data/default_skills/aicortex-cli-ops/SKILL.md +100 -0
- aicortexpy/data/default_skills/aicortex-graph/SKILL.md +30 -0
- aicortexpy/data/default_skills/graphify/SKILL.md +97 -0
- aicortexpy/data/default_skills/question-form/SKILL.md +162 -0
- aicortexpy/data/default_skills/test-report-generator/SKILL.md +179 -0
- aicortexpy/data/default_skills/test-report-generator/scripts/generate_cases.py +146 -0
- aicortexpy/data/default_skills/test-report-generator/scripts/generate_report.py +275 -0
- aicortexpy/design_seed.py +59 -0
- aicortexpy/diskusage.py +209 -0
- aicortexpy/execenv.py +264 -0
- aicortexpy/gc.py +286 -0
- aicortexpy/handoff_bundle.py +65 -0
- aicortexpy/health.py +178 -0
- aicortexpy/local_skills.py +157 -0
- aicortexpy/models.py +385 -0
- aicortexpy/openclaw_config.py +153 -0
- aicortexpy/pinned_workdir.py +60 -0
- aicortexpy/platform_info.py +61 -0
- aicortexpy/poisoned.py +44 -0
- aicortexpy/process.py +168 -0
- aicortexpy/procutil.py +61 -0
- aicortexpy/prompt.py +235 -0
- aicortexpy/repo.py +462 -0
- aicortexpy/runner.py +969 -0
- aicortexpy/runtime_config.py +540 -0
- aicortexpy/sandbox.py +264 -0
- aicortexpy/stream.py +422 -0
- aicortexpy/terminal.py +147 -0
- aicortexpy/tunnel.py +82 -0
- aicortexpy/wakeup.py +222 -0
- aicortexpy-0.1.0.dist-info/METADATA +106 -0
- aicortexpy-0.1.0.dist-info/RECORD +58 -0
- aicortexpy-0.1.0.dist-info/WHEEL +5 -0
- aicortexpy-0.1.0.dist-info/top_level.txt +1 -0
aicortexpy/__init__.py
ADDED
aicortexpy/__main__.py
ADDED
aicortexpy/acp_client.py
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
"""ACP JSON-RPC 2.0 client over newline-delimited stdin/stdout.
|
|
2
|
+
|
|
3
|
+
Port of the shared hermesClient used by Cursor/Kiro/Hermes ACP backends.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
import threading
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Callable, TextIO
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger("aicortexpy.acp")
|
|
16
|
+
|
|
17
|
+
MessageCB = Callable[[dict[str, Any]], None]
|
|
18
|
+
PromptDoneCB = Callable[["PromptResult"], None]
|
|
19
|
+
AcceptCB = Callable[[str], bool]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class TokenUsage:
|
|
24
|
+
input_tokens: int = 0
|
|
25
|
+
output_tokens: int = 0
|
|
26
|
+
cache_read_tokens: int = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class PromptResult:
|
|
31
|
+
stop_reason: str = ""
|
|
32
|
+
usage: TokenUsage = field(default_factory=TokenUsage)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class _PendingRPC:
|
|
37
|
+
event: threading.Event
|
|
38
|
+
method: str
|
|
39
|
+
result: Any = None
|
|
40
|
+
error: Exception | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class _PendingTool:
|
|
45
|
+
tool_name: str = ""
|
|
46
|
+
input: dict[str, Any] | None = None
|
|
47
|
+
args_text: str = ""
|
|
48
|
+
emitted: bool = False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ACPClient:
|
|
52
|
+
"""Thread-safe JSON-RPC client for one ACP child process."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, stdin: TextIO, *, tool_name_fn: Callable[[str, str], str] | None = None) -> None:
|
|
55
|
+
self._stdin = stdin
|
|
56
|
+
self._write_lock = threading.Lock()
|
|
57
|
+
self._mu = threading.Lock()
|
|
58
|
+
self._next_id = 1
|
|
59
|
+
self._pending: dict[int, _PendingRPC] = {}
|
|
60
|
+
self._tool_mu = threading.Lock()
|
|
61
|
+
self._pending_tools: dict[str, _PendingTool] = {}
|
|
62
|
+
self.tool_name_fn = tool_name_fn or default_tool_name
|
|
63
|
+
self.session_id = ""
|
|
64
|
+
self.on_message: MessageCB | None = None
|
|
65
|
+
self.on_prompt_done: PromptDoneCB | None = None
|
|
66
|
+
self.accept_notification: AcceptCB | None = None
|
|
67
|
+
self.usage = TokenUsage()
|
|
68
|
+
self._usage_lock = threading.Lock()
|
|
69
|
+
|
|
70
|
+
def write_line(self, data: bytes) -> None:
|
|
71
|
+
with self._write_lock:
|
|
72
|
+
self._stdin.buffer.write(data)
|
|
73
|
+
self._stdin.buffer.flush()
|
|
74
|
+
|
|
75
|
+
def request(self, method: str, params: Any, *, timeout: float) -> Any:
|
|
76
|
+
with self._mu:
|
|
77
|
+
req_id = self._next_id
|
|
78
|
+
self._next_id += 1
|
|
79
|
+
pending = _PendingRPC(event=threading.Event(), method=method)
|
|
80
|
+
self._pending[req_id] = pending
|
|
81
|
+
payload = {"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}
|
|
82
|
+
raw = (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")
|
|
83
|
+
try:
|
|
84
|
+
self.write_line(raw)
|
|
85
|
+
except OSError as e:
|
|
86
|
+
with self._mu:
|
|
87
|
+
self._pending.pop(req_id, None)
|
|
88
|
+
raise RuntimeError(f"write {method}: {e}") from e
|
|
89
|
+
if not pending.event.wait(timeout):
|
|
90
|
+
with self._mu:
|
|
91
|
+
self._pending.pop(req_id, None)
|
|
92
|
+
raise TimeoutError(f"{method} timed out after {timeout:.0f}s")
|
|
93
|
+
if pending.error:
|
|
94
|
+
raise pending.error
|
|
95
|
+
return pending.result
|
|
96
|
+
|
|
97
|
+
def close_all_pending(self, err: Exception) -> None:
|
|
98
|
+
with self._mu:
|
|
99
|
+
items = list(self._pending.items())
|
|
100
|
+
self._pending.clear()
|
|
101
|
+
for _, pr in items:
|
|
102
|
+
pr.error = err
|
|
103
|
+
pr.event.set()
|
|
104
|
+
|
|
105
|
+
def handle_line(self, line: str) -> None:
|
|
106
|
+
line = line.strip()
|
|
107
|
+
if not line:
|
|
108
|
+
return
|
|
109
|
+
try:
|
|
110
|
+
raw = json.loads(line)
|
|
111
|
+
except json.JSONDecodeError:
|
|
112
|
+
return
|
|
113
|
+
if not isinstance(raw, dict):
|
|
114
|
+
return
|
|
115
|
+
if "id" in raw:
|
|
116
|
+
if "result" in raw or "error" in raw:
|
|
117
|
+
self._handle_response(raw)
|
|
118
|
+
return
|
|
119
|
+
if "method" in raw:
|
|
120
|
+
self._handle_agent_request(raw)
|
|
121
|
+
return
|
|
122
|
+
if "method" in raw:
|
|
123
|
+
self._handle_notification(raw)
|
|
124
|
+
|
|
125
|
+
def _handle_response(self, raw: dict[str, Any]) -> None:
|
|
126
|
+
rid = raw.get("id")
|
|
127
|
+
try:
|
|
128
|
+
req_id = int(rid)
|
|
129
|
+
except (TypeError, ValueError):
|
|
130
|
+
return
|
|
131
|
+
with self._mu:
|
|
132
|
+
pr = self._pending.pop(req_id, None)
|
|
133
|
+
if pr is None:
|
|
134
|
+
return
|
|
135
|
+
if "error" in raw:
|
|
136
|
+
err = raw["error"] if isinstance(raw["error"], dict) else {}
|
|
137
|
+
msg = str(err.get("message") or "rpc error")
|
|
138
|
+
code = err.get("code")
|
|
139
|
+
data = err.get("data")
|
|
140
|
+
detail = ""
|
|
141
|
+
if data is not None and data != "null":
|
|
142
|
+
detail = data if isinstance(data, str) else json.dumps(data, ensure_ascii=False)
|
|
143
|
+
text = f"{pr.method}: {msg} (code={code})"
|
|
144
|
+
if detail:
|
|
145
|
+
text += f", data={detail}"
|
|
146
|
+
pr.error = RuntimeError(text)
|
|
147
|
+
else:
|
|
148
|
+
result = raw.get("result")
|
|
149
|
+
if pr.method == "session/prompt":
|
|
150
|
+
self._extract_prompt_result(result)
|
|
151
|
+
pr.result = result
|
|
152
|
+
pr.event.set()
|
|
153
|
+
|
|
154
|
+
def _handle_agent_request(self, raw: dict[str, Any]) -> None:
|
|
155
|
+
method = str(raw.get("method") or "")
|
|
156
|
+
req_id = raw.get("id")
|
|
157
|
+
if method == "session/request_permission":
|
|
158
|
+
option_id = _select_permission_option_id(raw.get("params"))
|
|
159
|
+
result = {"outcome": {"outcome": "selected", "optionId": option_id}}
|
|
160
|
+
log.debug("auto-approved permission option_id=%s", option_id)
|
|
161
|
+
elif method == "cursor/ask_question":
|
|
162
|
+
result = {
|
|
163
|
+
"outcome": {
|
|
164
|
+
"outcome": "skipped",
|
|
165
|
+
"reason": "headless daemon — no interactive UI",
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
elif method == "cursor/create_plan":
|
|
169
|
+
result = {"outcome": {"outcome": "accepted"}}
|
|
170
|
+
elif method == "cursor/task":
|
|
171
|
+
result = {
|
|
172
|
+
"outcome": {
|
|
173
|
+
"outcome": "rejected",
|
|
174
|
+
"reason": (
|
|
175
|
+
"headless daemon — subagent delegation unavailable; "
|
|
176
|
+
"use main session tools"
|
|
177
|
+
),
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
else:
|
|
181
|
+
resp = {
|
|
182
|
+
"jsonrpc": "2.0",
|
|
183
|
+
"id": req_id,
|
|
184
|
+
"error": {"code": -32601, "message": f"method not found: {method}"},
|
|
185
|
+
}
|
|
186
|
+
try:
|
|
187
|
+
self.write_line((json.dumps(resp) + "\n").encode("utf-8"))
|
|
188
|
+
except OSError:
|
|
189
|
+
pass
|
|
190
|
+
return
|
|
191
|
+
resp = {"jsonrpc": "2.0", "id": req_id, "result": result}
|
|
192
|
+
try:
|
|
193
|
+
self.write_line((json.dumps(resp, ensure_ascii=False) + "\n").encode("utf-8"))
|
|
194
|
+
except OSError as e:
|
|
195
|
+
log.warning("write agent-request response failed: %s", e)
|
|
196
|
+
|
|
197
|
+
def _handle_notification(self, raw: dict[str, Any]) -> None:
|
|
198
|
+
method = str(raw.get("method") or "")
|
|
199
|
+
if method not in ("session/update", "session/notification"):
|
|
200
|
+
return
|
|
201
|
+
params = raw.get("params") if isinstance(raw.get("params"), dict) else {}
|
|
202
|
+
update = params.get("update")
|
|
203
|
+
if update is None:
|
|
204
|
+
return
|
|
205
|
+
update_type, update_data = _normalize_update(update)
|
|
206
|
+
if self.accept_notification and not self.accept_notification(update_type):
|
|
207
|
+
return
|
|
208
|
+
if update_type == "agent_message_chunk":
|
|
209
|
+
text = _content_text(update_data)
|
|
210
|
+
if text and self.on_message:
|
|
211
|
+
self.on_message({"type": "text", "content": text})
|
|
212
|
+
elif update_type == "agent_thought_chunk":
|
|
213
|
+
text = _content_text(update_data)
|
|
214
|
+
if text and self.on_message:
|
|
215
|
+
self.on_message({"type": "thinking", "content": text})
|
|
216
|
+
elif update_type == "tool_call":
|
|
217
|
+
self._handle_tool_call_start(update_data)
|
|
218
|
+
elif update_type == "tool_call_update":
|
|
219
|
+
self._handle_tool_call_update(update_data)
|
|
220
|
+
elif update_type == "usage_update":
|
|
221
|
+
self._handle_usage_update(update_data)
|
|
222
|
+
elif update_type == "turn_end":
|
|
223
|
+
self._extract_prompt_result(update_data)
|
|
224
|
+
|
|
225
|
+
def _handle_tool_call_start(self, data: Any) -> None:
|
|
226
|
+
if not isinstance(data, dict):
|
|
227
|
+
return
|
|
228
|
+
call_id = str(data.get("toolCallId") or "")
|
|
229
|
+
title = str(data.get("title") or "")
|
|
230
|
+
kind = str(data.get("kind") or "")
|
|
231
|
+
name = str(data.get("name") or "")
|
|
232
|
+
tool_name = self.tool_name_fn(title, kind) or name
|
|
233
|
+
raw_input = data.get("rawInput") or data.get("input") or data.get("parameters")
|
|
234
|
+
if isinstance(raw_input, dict):
|
|
235
|
+
self._track_tool(call_id, _PendingTool(tool_name=tool_name, input=raw_input, emitted=True))
|
|
236
|
+
if self.on_message:
|
|
237
|
+
self.on_message(
|
|
238
|
+
{
|
|
239
|
+
"type": "tool-use",
|
|
240
|
+
"tool": tool_name,
|
|
241
|
+
"call_id": call_id,
|
|
242
|
+
"input": raw_input,
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
return
|
|
246
|
+
content = data.get("content") if isinstance(data.get("content"), list) else []
|
|
247
|
+
self._track_tool(
|
|
248
|
+
call_id,
|
|
249
|
+
_PendingTool(
|
|
250
|
+
tool_name=tool_name,
|
|
251
|
+
args_text=_extract_tool_call_text(content),
|
|
252
|
+
emitted=False,
|
|
253
|
+
),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def _handle_tool_call_update(self, data: Any) -> None:
|
|
257
|
+
if not isinstance(data, dict):
|
|
258
|
+
return
|
|
259
|
+
call_id = str(data.get("toolCallId") or "")
|
|
260
|
+
status = str(data.get("status") or "")
|
|
261
|
+
content = data.get("content") if isinstance(data.get("content"), list) else []
|
|
262
|
+
if status not in ("completed", "failed"):
|
|
263
|
+
pending = self._get_pending_tool(call_id)
|
|
264
|
+
if pending is not None and not pending.emitted:
|
|
265
|
+
text = _extract_tool_call_text(content)
|
|
266
|
+
if text:
|
|
267
|
+
pending.args_text = text
|
|
268
|
+
return
|
|
269
|
+
pending = self._take_pending_tool(call_id)
|
|
270
|
+
self._emit_deferred_tool_use(pending, call_id, data)
|
|
271
|
+
output = str(data.get("rawOutput") or data.get("output") or "")
|
|
272
|
+
if not output:
|
|
273
|
+
output = _extract_tool_call_text(content)
|
|
274
|
+
if self.on_message:
|
|
275
|
+
self.on_message({"type": "tool-result", "call_id": call_id, "output": output})
|
|
276
|
+
|
|
277
|
+
def _emit_deferred_tool_use(self, pending: _PendingTool | None, call_id: str, data: dict) -> None:
|
|
278
|
+
if pending is not None and pending.emitted:
|
|
279
|
+
return
|
|
280
|
+
title = str(data.get("title") or data.get("name") or "")
|
|
281
|
+
kind = str(data.get("kind") or "")
|
|
282
|
+
raw_input = data.get("rawInput") or data.get("input") or data.get("parameters")
|
|
283
|
+
tool_name = ""
|
|
284
|
+
inp: dict[str, Any] | None = None
|
|
285
|
+
if pending is not None:
|
|
286
|
+
tool_name = pending.tool_name
|
|
287
|
+
if pending.input is not None:
|
|
288
|
+
inp = pending.input
|
|
289
|
+
elif pending.args_text:
|
|
290
|
+
try:
|
|
291
|
+
parsed = json.loads(pending.args_text)
|
|
292
|
+
inp = parsed if isinstance(parsed, dict) else {"text": pending.args_text}
|
|
293
|
+
except json.JSONDecodeError:
|
|
294
|
+
inp = {"text": pending.args_text}
|
|
295
|
+
if not tool_name:
|
|
296
|
+
tool_name = self.tool_name_fn(title, kind) or str(data.get("name") or "tool")
|
|
297
|
+
if inp is None and isinstance(raw_input, dict):
|
|
298
|
+
inp = raw_input
|
|
299
|
+
if inp is None:
|
|
300
|
+
inp = {}
|
|
301
|
+
if self.on_message:
|
|
302
|
+
self.on_message(
|
|
303
|
+
{"type": "tool-use", "tool": tool_name, "call_id": call_id, "input": inp}
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
def _track_tool(self, call_id: str, p: _PendingTool) -> None:
|
|
307
|
+
if not call_id:
|
|
308
|
+
return
|
|
309
|
+
with self._tool_mu:
|
|
310
|
+
self._pending_tools[call_id] = p
|
|
311
|
+
|
|
312
|
+
def _get_pending_tool(self, call_id: str) -> _PendingTool | None:
|
|
313
|
+
with self._tool_mu:
|
|
314
|
+
return self._pending_tools.get(call_id)
|
|
315
|
+
|
|
316
|
+
def _take_pending_tool(self, call_id: str) -> _PendingTool | None:
|
|
317
|
+
with self._tool_mu:
|
|
318
|
+
return self._pending_tools.pop(call_id, None)
|
|
319
|
+
|
|
320
|
+
def _extract_prompt_result(self, data: Any) -> None:
|
|
321
|
+
if not isinstance(data, dict):
|
|
322
|
+
return
|
|
323
|
+
pr = PromptResult(stop_reason=str(data.get("stopReason") or ""))
|
|
324
|
+
usage = data.get("usage")
|
|
325
|
+
if isinstance(usage, dict):
|
|
326
|
+
pr.usage = TokenUsage(
|
|
327
|
+
input_tokens=int(usage.get("inputTokens") or 0),
|
|
328
|
+
output_tokens=int(usage.get("outputTokens") or 0),
|
|
329
|
+
cache_read_tokens=int(usage.get("cachedReadTokens") or 0),
|
|
330
|
+
)
|
|
331
|
+
if self.on_prompt_done:
|
|
332
|
+
self.on_prompt_done(pr)
|
|
333
|
+
|
|
334
|
+
def _handle_usage_update(self, data: Any) -> None:
|
|
335
|
+
if not isinstance(data, dict):
|
|
336
|
+
return
|
|
337
|
+
usage = data.get("usage") if isinstance(data.get("usage"), dict) else data
|
|
338
|
+
if not isinstance(usage, dict):
|
|
339
|
+
return
|
|
340
|
+
with self._usage_lock:
|
|
341
|
+
self.usage.input_tokens = int(
|
|
342
|
+
usage.get("inputTokens") or usage.get("input_tokens") or self.usage.input_tokens
|
|
343
|
+
)
|
|
344
|
+
self.usage.output_tokens = int(
|
|
345
|
+
usage.get("outputTokens") or usage.get("output_tokens") or self.usage.output_tokens
|
|
346
|
+
)
|
|
347
|
+
self.usage.cache_read_tokens = int(
|
|
348
|
+
usage.get("cachedReadTokens")
|
|
349
|
+
or usage.get("cache_read_tokens")
|
|
350
|
+
or self.usage.cache_read_tokens
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def extract_session_id(result: Any) -> str:
|
|
355
|
+
if not isinstance(result, dict):
|
|
356
|
+
return ""
|
|
357
|
+
for key in ("sessionId", "session_id", "id"):
|
|
358
|
+
v = result.get(key)
|
|
359
|
+
if isinstance(v, str) and v.strip():
|
|
360
|
+
return v.strip()
|
|
361
|
+
session = result.get("session")
|
|
362
|
+
if isinstance(session, dict):
|
|
363
|
+
return extract_session_id(session)
|
|
364
|
+
return ""
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def resolve_resumed_session_id(requested: str, response: Any) -> tuple[str, bool]:
|
|
368
|
+
actual = extract_session_id(response) or requested
|
|
369
|
+
return actual, bool(actual and requested and actual != requested)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _select_permission_option_id(params: Any) -> str:
|
|
373
|
+
fallback = "approve_for_session"
|
|
374
|
+
if not isinstance(params, dict):
|
|
375
|
+
return fallback
|
|
376
|
+
options = params.get("options")
|
|
377
|
+
if not isinstance(options, list) or not options:
|
|
378
|
+
return fallback
|
|
379
|
+
prefer = ("allow-always", "approve_for_session", "allow-once", "approve")
|
|
380
|
+
by_id = {
|
|
381
|
+
str(o.get("optionId") or ""): o
|
|
382
|
+
for o in options
|
|
383
|
+
if isinstance(o, dict)
|
|
384
|
+
}
|
|
385
|
+
for want in prefer:
|
|
386
|
+
if want in by_id:
|
|
387
|
+
return want
|
|
388
|
+
for o in options:
|
|
389
|
+
if isinstance(o, dict) and o.get("kind") == "allow_always":
|
|
390
|
+
return str(o.get("optionId") or fallback)
|
|
391
|
+
for o in options:
|
|
392
|
+
if isinstance(o, dict) and o.get("kind") == "allow_once":
|
|
393
|
+
return str(o.get("optionId") or fallback)
|
|
394
|
+
first = options[0]
|
|
395
|
+
if isinstance(first, dict):
|
|
396
|
+
return str(first.get("optionId") or fallback)
|
|
397
|
+
return fallback
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _normalize_update(data: Any) -> tuple[str, Any]:
|
|
401
|
+
if not isinstance(data, dict):
|
|
402
|
+
return "", data
|
|
403
|
+
session_update = str(data.get("sessionUpdate") or "")
|
|
404
|
+
typ = str(data.get("type") or "")
|
|
405
|
+
if session_update:
|
|
406
|
+
return _normalize_type(session_update), data
|
|
407
|
+
if typ:
|
|
408
|
+
return _normalize_type(typ), data
|
|
409
|
+
if len(data) == 1:
|
|
410
|
+
k, v = next(iter(data.items()))
|
|
411
|
+
return _normalize_type(str(k)), v
|
|
412
|
+
return "", data
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _normalize_type(t: str) -> str:
|
|
416
|
+
key = re.sub(r"[\s_\-]", "", t.strip().lower())
|
|
417
|
+
mapping = {
|
|
418
|
+
"agentmessagechunk": "agent_message_chunk",
|
|
419
|
+
"agentthoughtchunk": "agent_thought_chunk",
|
|
420
|
+
"toolcall": "tool_call",
|
|
421
|
+
"toolcallupdate": "tool_call_update",
|
|
422
|
+
"usageupdate": "usage_update",
|
|
423
|
+
"turnend": "turn_end",
|
|
424
|
+
"endturn": "turn_end",
|
|
425
|
+
}
|
|
426
|
+
return mapping.get(key, "")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _content_text(data: Any) -> str:
|
|
430
|
+
if not isinstance(data, dict):
|
|
431
|
+
return ""
|
|
432
|
+
content = data.get("content")
|
|
433
|
+
if isinstance(content, dict):
|
|
434
|
+
return str(content.get("text") or "")
|
|
435
|
+
return str(data.get("text") or "")
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _extract_tool_call_text(blocks: Any) -> str:
|
|
439
|
+
if not isinstance(blocks, list):
|
|
440
|
+
return ""
|
|
441
|
+
parts: list[str] = []
|
|
442
|
+
for raw in blocks:
|
|
443
|
+
if not isinstance(raw, dict):
|
|
444
|
+
continue
|
|
445
|
+
typ = str(raw.get("type") or "")
|
|
446
|
+
if typ == "content":
|
|
447
|
+
inner = raw.get("content")
|
|
448
|
+
if isinstance(inner, dict) and str(inner.get("type") or "") == "text":
|
|
449
|
+
text = str(inner.get("text") or "")
|
|
450
|
+
if text:
|
|
451
|
+
parts.append(text)
|
|
452
|
+
elif typ == "diff":
|
|
453
|
+
path = str(raw.get("path") or "")
|
|
454
|
+
if not path:
|
|
455
|
+
continue
|
|
456
|
+
old = str(raw.get("oldText") or "")
|
|
457
|
+
new = str(raw.get("newText") or "")
|
|
458
|
+
if not old:
|
|
459
|
+
parts.append(f"--- {path}\n+++ {path}\n(new file, {len(new)} bytes)")
|
|
460
|
+
else:
|
|
461
|
+
parts.append(
|
|
462
|
+
f"--- {path}\n+++ {path}\n(edited: {len(old)} → {len(new)} bytes)"
|
|
463
|
+
)
|
|
464
|
+
return "\n".join(parts)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def default_tool_name(title: str, kind: str = "") -> str:
|
|
468
|
+
return hermes_tool_name_from_title(title, kind)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def hermes_tool_name_from_title(title: str, kind: str = "") -> str:
|
|
472
|
+
if title == "execute code":
|
|
473
|
+
return "execute_code"
|
|
474
|
+
if ":" in title:
|
|
475
|
+
name = title.split(":", 1)[0].strip()
|
|
476
|
+
if name == "terminal":
|
|
477
|
+
return "terminal"
|
|
478
|
+
if name == "read":
|
|
479
|
+
return "read_file"
|
|
480
|
+
if name == "write":
|
|
481
|
+
return "write_file"
|
|
482
|
+
if name.startswith("patch"):
|
|
483
|
+
return "patch"
|
|
484
|
+
if name == "search":
|
|
485
|
+
return "search_files"
|
|
486
|
+
if name == "web search":
|
|
487
|
+
return "web_search"
|
|
488
|
+
if name == "extract":
|
|
489
|
+
return "web_extract"
|
|
490
|
+
if name == "delegate":
|
|
491
|
+
return "delegate_task"
|
|
492
|
+
if name == "analyze image":
|
|
493
|
+
return "vision_analyze"
|
|
494
|
+
return name
|
|
495
|
+
kind_map = {
|
|
496
|
+
"read": "read_file",
|
|
497
|
+
"edit": "write_file",
|
|
498
|
+
"execute": "terminal",
|
|
499
|
+
"search": "search_files",
|
|
500
|
+
"fetch": "web_search",
|
|
501
|
+
"think": "thinking",
|
|
502
|
+
}
|
|
503
|
+
if kind in kind_map:
|
|
504
|
+
return kind_map[kind]
|
|
505
|
+
return title or kind
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def normalize_title_tool(title: str, mapping: dict[str, str] | None = None) -> str:
|
|
509
|
+
t = (title or "").strip()
|
|
510
|
+
if not t:
|
|
511
|
+
return ""
|
|
512
|
+
if ":" in t:
|
|
513
|
+
t = t.split(":", 1)[0].strip()
|
|
514
|
+
lower = t.lower()
|
|
515
|
+
base = {
|
|
516
|
+
"read": "read_file",
|
|
517
|
+
"read file": "read_file",
|
|
518
|
+
"write": "write_file",
|
|
519
|
+
"write file": "write_file",
|
|
520
|
+
"edit": "edit_file",
|
|
521
|
+
"patch": "edit_file",
|
|
522
|
+
"shell": "terminal",
|
|
523
|
+
"bash": "terminal",
|
|
524
|
+
"terminal": "terminal",
|
|
525
|
+
"run command": "terminal",
|
|
526
|
+
"run shell command": "terminal",
|
|
527
|
+
"execute": "terminal",
|
|
528
|
+
"grep": "search_files",
|
|
529
|
+
"search": "search_files",
|
|
530
|
+
"find": "search_files",
|
|
531
|
+
"glob": "glob",
|
|
532
|
+
"code": "code",
|
|
533
|
+
"web search": "web_search",
|
|
534
|
+
"fetch": "web_fetch",
|
|
535
|
+
"web fetch": "web_fetch",
|
|
536
|
+
"todo": "todo_write",
|
|
537
|
+
"todo write": "todo_write",
|
|
538
|
+
"todo list": "todo_write",
|
|
539
|
+
"todo_list": "todo_write",
|
|
540
|
+
}
|
|
541
|
+
if mapping:
|
|
542
|
+
base = {**base, **mapping}
|
|
543
|
+
if lower in base:
|
|
544
|
+
return base[lower]
|
|
545
|
+
return lower.replace(" ", "_")
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def kiro_tool_name(title: str, kind: str = "") -> str:
|
|
549
|
+
return normalize_title_tool(title) or hermes_tool_name_from_title(title, kind)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def kimi_tool_name(title: str, kind: str = "") -> str:
|
|
553
|
+
# Upstream hermes mapping first, then kimi capitalised titles.
|
|
554
|
+
mapped = hermes_tool_name_from_title(title, kind)
|
|
555
|
+
return normalize_title_tool(mapped) or normalize_title_tool(title)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def dsh_tool_name(title: str, kind: str = "") -> str:
|
|
559
|
+
return normalize_title_tool(title) or hermes_tool_name_from_title(title, kind)
|