agentlink-cli 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.
- agentlink_cli-0.1.0.dist-info/METADATA +136 -0
- agentlink_cli-0.1.0.dist-info/RECORD +55 -0
- agentlink_cli-0.1.0.dist-info/WHEEL +4 -0
- agentlink_cli-0.1.0.dist-info/entry_points.txt +3 -0
- connector/__init__.py +3 -0
- connector/acp/__init__.py +6 -0
- connector/acp/adapter.py +1221 -0
- connector/acp/config_options.py +175 -0
- connector/acp/discovery.py +385 -0
- connector/acp/manifest.py +110 -0
- connector/acp/manifests/__init__.py +1 -0
- connector/acp/manifests/codebuddy.json +37 -0
- connector/acp/manifests/cursor.json +39 -0
- connector/acp/manifests/gemini.json +33 -0
- connector/acp/manifests/grok_build.json +31 -0
- connector/acp/reducer.py +615 -0
- connector/acp/rpc.py +308 -0
- connector/adapter.py +39 -0
- connector/attachments.py +36 -0
- connector/capabilities.py +603 -0
- connector/claude/__init__.py +8 -0
- connector/claude/history_adapter.py +642 -0
- connector/claude/normalized.py +23 -0
- connector/claude/normalizers.py +97 -0
- connector/claude/path_utils.py +13 -0
- connector/claude/preferences.py +38 -0
- connector/claude/sdk_adapter.py +1376 -0
- connector/claude/timeline_identity.py +47 -0
- connector/claude/timeline_reducer.py +379 -0
- connector/claude/trust.py +69 -0
- connector/cli.py +280 -0
- connector/codex/__init__.py +3 -0
- connector/codex/adapter.py +1150 -0
- connector/codex/history.py +199 -0
- connector/codex/reducer.py +1309 -0
- connector/codex/rpc.py +261 -0
- connector/control.py +298 -0
- connector/json_rpc.py +143 -0
- connector/launch.py +310 -0
- connector/local/__init__.py +6 -0
- connector/local/common.py +118 -0
- connector/local/file_ops.py +144 -0
- connector/local/ops.py +92 -0
- connector/local/shell.py +225 -0
- connector/local/terminal.py +658 -0
- connector/local_ops.py +5 -0
- connector/local_runtime.py +139 -0
- connector/logging.py +50 -0
- connector/perf.py +89 -0
- connector/protocol.py +26 -0
- connector/registry.py +49 -0
- connector/runtime.py +1309 -0
- connector/sync_state.py +155 -0
- connector/time.py +7 -0
- connector/version.py +13 -0
connector/acp/reducer.py
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from connector.time import utc_now
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class AcpReductionResult:
|
|
13
|
+
timeline_items: list[dict[str, Any]] = field(default_factory=list)
|
|
14
|
+
session_update: dict[str, Any] | None = None
|
|
15
|
+
approval: dict[str, Any] | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class AcpTimelineReducer:
|
|
20
|
+
"""Map ACP session/update notifications to AgentLink timeline items."""
|
|
21
|
+
|
|
22
|
+
runtime: str
|
|
23
|
+
_next_order: int = 1
|
|
24
|
+
_order_by_item: dict[str, int] = field(default_factory=dict)
|
|
25
|
+
_items: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
26
|
+
_message_text: dict[str, str] = field(default_factory=dict)
|
|
27
|
+
_tool_state: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
def reset_turn(self) -> None:
|
|
30
|
+
# Keep order counters for session stability; clear only streaming buffers.
|
|
31
|
+
self._message_text.clear()
|
|
32
|
+
|
|
33
|
+
def turn_start(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
session_id: str,
|
|
37
|
+
turn_id: str,
|
|
38
|
+
external_session_id: str | None,
|
|
39
|
+
content: str | None = None,
|
|
40
|
+
client_message_id: str | None = None,
|
|
41
|
+
attachments: list[dict[str, Any]] | None = None,
|
|
42
|
+
) -> AcpReductionResult:
|
|
43
|
+
items: list[dict[str, Any]] = []
|
|
44
|
+
items.append(
|
|
45
|
+
self._upsert(
|
|
46
|
+
session_id=session_id,
|
|
47
|
+
turn_id=turn_id,
|
|
48
|
+
item_id=f"{turn_id}:start",
|
|
49
|
+
item_type="turn.start",
|
|
50
|
+
status="running",
|
|
51
|
+
role=None,
|
|
52
|
+
content={},
|
|
53
|
+
external_session_id=external_session_id,
|
|
54
|
+
event="turn.start",
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
if content:
|
|
58
|
+
user_content: dict[str, Any] = {"text": content, "format": "markdown"}
|
|
59
|
+
if attachments:
|
|
60
|
+
user_content["attachments"] = attachments
|
|
61
|
+
source_extra = {"clientMessageId": client_message_id} if client_message_id else None
|
|
62
|
+
items.append(
|
|
63
|
+
self._upsert(
|
|
64
|
+
session_id=session_id,
|
|
65
|
+
turn_id=turn_id,
|
|
66
|
+
item_id=f"{turn_id}:user",
|
|
67
|
+
item_type="message",
|
|
68
|
+
status="done",
|
|
69
|
+
role="user",
|
|
70
|
+
content=user_content,
|
|
71
|
+
external_session_id=external_session_id,
|
|
72
|
+
event="user_message",
|
|
73
|
+
source_extra=source_extra,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
return AcpReductionResult(
|
|
77
|
+
timeline_items=items,
|
|
78
|
+
session_update={
|
|
79
|
+
"sessionId": session_id,
|
|
80
|
+
"runtime": self.runtime,
|
|
81
|
+
"externalSessionId": external_session_id,
|
|
82
|
+
"status": "running",
|
|
83
|
+
"sourceObservedAt": utc_now(),
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def turn_end(
|
|
88
|
+
self,
|
|
89
|
+
*,
|
|
90
|
+
session_id: str,
|
|
91
|
+
turn_id: str,
|
|
92
|
+
external_session_id: str | None,
|
|
93
|
+
stop_reason: str | None,
|
|
94
|
+
interrupted: bool = False,
|
|
95
|
+
) -> AcpReductionResult:
|
|
96
|
+
status, result = _stop_reason_to_status(stop_reason, interrupted=interrupted)
|
|
97
|
+
item = self._upsert(
|
|
98
|
+
session_id=session_id,
|
|
99
|
+
turn_id=turn_id,
|
|
100
|
+
item_id=f"{turn_id}:end",
|
|
101
|
+
item_type="turn.end",
|
|
102
|
+
status=status,
|
|
103
|
+
role=None,
|
|
104
|
+
content={"result": result, "stopReason": stop_reason or result},
|
|
105
|
+
external_session_id=external_session_id,
|
|
106
|
+
event="turn.end",
|
|
107
|
+
completed_at=utc_now(),
|
|
108
|
+
)
|
|
109
|
+
# Mark in-flight assistant messages done.
|
|
110
|
+
finalized: list[dict[str, Any]] = [item]
|
|
111
|
+
for existing in list(self._items.values()):
|
|
112
|
+
if (
|
|
113
|
+
existing.get("turnId") == turn_id
|
|
114
|
+
and existing.get("type") == "message"
|
|
115
|
+
and existing.get("role") == "assistant"
|
|
116
|
+
and existing.get("status") == "running"
|
|
117
|
+
):
|
|
118
|
+
finalized.append(
|
|
119
|
+
self._upsert(
|
|
120
|
+
session_id=session_id,
|
|
121
|
+
turn_id=turn_id,
|
|
122
|
+
item_id=str(existing["source"].get("itemId") or existing["id"]),
|
|
123
|
+
item_type="message",
|
|
124
|
+
status="done",
|
|
125
|
+
role="assistant",
|
|
126
|
+
content=existing.get("content") or {},
|
|
127
|
+
external_session_id=external_session_id,
|
|
128
|
+
event="agent_message_chunk",
|
|
129
|
+
completed_at=utc_now(),
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
return AcpReductionResult(
|
|
133
|
+
timeline_items=finalized,
|
|
134
|
+
session_update={
|
|
135
|
+
"sessionId": session_id,
|
|
136
|
+
"runtime": self.runtime,
|
|
137
|
+
"externalSessionId": external_session_id,
|
|
138
|
+
"status": "idle" if status != "waiting_approval" else "waiting_approval",
|
|
139
|
+
"sourceObservedAt": utc_now(),
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def reduce_session_update(
|
|
144
|
+
self,
|
|
145
|
+
*,
|
|
146
|
+
session_id: str,
|
|
147
|
+
turn_id: str,
|
|
148
|
+
external_session_id: str | None,
|
|
149
|
+
update: dict[str, Any],
|
|
150
|
+
) -> AcpReductionResult:
|
|
151
|
+
kind = update.get("sessionUpdate") or update.get("session_update")
|
|
152
|
+
if not isinstance(kind, str):
|
|
153
|
+
return AcpReductionResult()
|
|
154
|
+
|
|
155
|
+
if kind in {"agent_message_chunk", "user_message_chunk", "agent_thought_chunk"}:
|
|
156
|
+
return self._reduce_message_chunk(
|
|
157
|
+
session_id=session_id,
|
|
158
|
+
turn_id=turn_id,
|
|
159
|
+
external_session_id=external_session_id,
|
|
160
|
+
kind=kind,
|
|
161
|
+
update=update,
|
|
162
|
+
)
|
|
163
|
+
if kind in {"tool_call", "tool_call_update"}:
|
|
164
|
+
return self._reduce_tool_call(
|
|
165
|
+
session_id=session_id,
|
|
166
|
+
turn_id=turn_id,
|
|
167
|
+
external_session_id=external_session_id,
|
|
168
|
+
update=update,
|
|
169
|
+
is_update=kind == "tool_call_update",
|
|
170
|
+
)
|
|
171
|
+
if kind == "plan":
|
|
172
|
+
entries = update.get("entries") if isinstance(update.get("entries"), list) else []
|
|
173
|
+
text = "\n".join(
|
|
174
|
+
str(entry.get("content") or "")
|
|
175
|
+
for entry in entries
|
|
176
|
+
if isinstance(entry, dict) and entry.get("content")
|
|
177
|
+
)
|
|
178
|
+
if not text:
|
|
179
|
+
return AcpReductionResult()
|
|
180
|
+
item = self._upsert(
|
|
181
|
+
session_id=session_id,
|
|
182
|
+
turn_id=turn_id,
|
|
183
|
+
item_id=f"{turn_id}:plan",
|
|
184
|
+
item_type="system",
|
|
185
|
+
status="done",
|
|
186
|
+
role="system",
|
|
187
|
+
content={"text": text, "format": "markdown", "kind": "plan"},
|
|
188
|
+
external_session_id=external_session_id,
|
|
189
|
+
event="plan",
|
|
190
|
+
)
|
|
191
|
+
return AcpReductionResult(timeline_items=[item])
|
|
192
|
+
if kind == "session_info_update":
|
|
193
|
+
session_update: dict[str, Any] = {
|
|
194
|
+
"sessionId": session_id,
|
|
195
|
+
"runtime": self.runtime,
|
|
196
|
+
"externalSessionId": external_session_id,
|
|
197
|
+
"sourceObservedAt": utc_now(),
|
|
198
|
+
}
|
|
199
|
+
if "title" in update:
|
|
200
|
+
session_update["title"] = update.get("title")
|
|
201
|
+
return AcpReductionResult(session_update=session_update)
|
|
202
|
+
return AcpReductionResult()
|
|
203
|
+
|
|
204
|
+
def reduce_permission_request(
|
|
205
|
+
self,
|
|
206
|
+
*,
|
|
207
|
+
session_id: str,
|
|
208
|
+
turn_id: str,
|
|
209
|
+
external_session_id: str | None,
|
|
210
|
+
request_id: str | int,
|
|
211
|
+
params: dict[str, Any],
|
|
212
|
+
) -> AcpReductionResult:
|
|
213
|
+
tool_call = params.get("toolCall") if isinstance(params.get("toolCall"), dict) else {}
|
|
214
|
+
tool_call_id = str(tool_call.get("toolCallId") or tool_call.get("tool_call_id") or request_id)
|
|
215
|
+
title = str(
|
|
216
|
+
tool_call.get("title")
|
|
217
|
+
or params.get("title")
|
|
218
|
+
or f"{self.runtime} requests permission"
|
|
219
|
+
)
|
|
220
|
+
options = params.get("options") if isinstance(params.get("options"), list) else []
|
|
221
|
+
choices = _permission_choices(options)
|
|
222
|
+
kind = _tool_kind_to_approval_kind(tool_call.get("kind"))
|
|
223
|
+
tool_item = self._upsert(
|
|
224
|
+
session_id=session_id,
|
|
225
|
+
turn_id=turn_id,
|
|
226
|
+
item_id=tool_call_id,
|
|
227
|
+
item_type="tool",
|
|
228
|
+
status="waiting_approval",
|
|
229
|
+
role="tool",
|
|
230
|
+
content={
|
|
231
|
+
"toolCallId": tool_call_id,
|
|
232
|
+
"title": title,
|
|
233
|
+
"kind": _map_tool_kind(tool_call.get("kind")),
|
|
234
|
+
"rawInput": tool_call.get("rawInput") or tool_call.get("raw_input"),
|
|
235
|
+
},
|
|
236
|
+
external_session_id=external_session_id,
|
|
237
|
+
event="request_permission",
|
|
238
|
+
)
|
|
239
|
+
approval = {
|
|
240
|
+
"id": f"appr_{self.runtime}_{_short_hash([session_id, str(request_id)])}",
|
|
241
|
+
"sessionId": session_id,
|
|
242
|
+
"turnId": turn_id,
|
|
243
|
+
"status": "pending",
|
|
244
|
+
"kind": kind,
|
|
245
|
+
"targetItemId": tool_item["id"],
|
|
246
|
+
"title": title,
|
|
247
|
+
"description": _permission_description(options),
|
|
248
|
+
"payload": {
|
|
249
|
+
"toolCall": tool_call,
|
|
250
|
+
"options": options,
|
|
251
|
+
"requestId": request_id,
|
|
252
|
+
},
|
|
253
|
+
"choices": choices,
|
|
254
|
+
"source": {
|
|
255
|
+
"runtime": self.runtime,
|
|
256
|
+
"requestId": request_id,
|
|
257
|
+
"sessionId": external_session_id,
|
|
258
|
+
"turnId": turn_id,
|
|
259
|
+
"itemId": tool_call_id,
|
|
260
|
+
"method": "session/request_permission",
|
|
261
|
+
},
|
|
262
|
+
"createdAt": utc_now(),
|
|
263
|
+
}
|
|
264
|
+
return AcpReductionResult(
|
|
265
|
+
timeline_items=[tool_item],
|
|
266
|
+
approval=approval,
|
|
267
|
+
session_update={
|
|
268
|
+
"sessionId": session_id,
|
|
269
|
+
"runtime": self.runtime,
|
|
270
|
+
"externalSessionId": external_session_id,
|
|
271
|
+
"status": "waiting_approval",
|
|
272
|
+
"sourceObservedAt": utc_now(),
|
|
273
|
+
},
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def _reduce_message_chunk(
|
|
277
|
+
self,
|
|
278
|
+
*,
|
|
279
|
+
session_id: str,
|
|
280
|
+
turn_id: str,
|
|
281
|
+
external_session_id: str | None,
|
|
282
|
+
kind: str,
|
|
283
|
+
update: dict[str, Any],
|
|
284
|
+
) -> AcpReductionResult:
|
|
285
|
+
content_block = update.get("content") if isinstance(update.get("content"), dict) else {}
|
|
286
|
+
delta = ""
|
|
287
|
+
if isinstance(content_block.get("text"), str):
|
|
288
|
+
delta = content_block["text"]
|
|
289
|
+
elif isinstance(update.get("text"), str):
|
|
290
|
+
delta = update["text"]
|
|
291
|
+
if not delta and kind != "agent_thought_chunk":
|
|
292
|
+
return AcpReductionResult()
|
|
293
|
+
|
|
294
|
+
message_id = (
|
|
295
|
+
str(update.get("messageId") or update.get("message_id") or "")
|
|
296
|
+
or f"{turn_id}:{kind}"
|
|
297
|
+
)
|
|
298
|
+
previous = self._message_text.get(message_id, "")
|
|
299
|
+
text = previous + delta
|
|
300
|
+
self._message_text[message_id] = text
|
|
301
|
+
|
|
302
|
+
if kind == "user_message_chunk":
|
|
303
|
+
role = "user"
|
|
304
|
+
item_type = "message"
|
|
305
|
+
status = "done"
|
|
306
|
+
elif kind == "agent_thought_chunk":
|
|
307
|
+
role = "assistant"
|
|
308
|
+
item_type = "message"
|
|
309
|
+
status = "running"
|
|
310
|
+
content = {"text": text, "format": "markdown", "kind": "thinking"}
|
|
311
|
+
item = self._upsert(
|
|
312
|
+
session_id=session_id,
|
|
313
|
+
turn_id=turn_id,
|
|
314
|
+
item_id=message_id,
|
|
315
|
+
item_type=item_type,
|
|
316
|
+
status=status,
|
|
317
|
+
role=role,
|
|
318
|
+
content=content,
|
|
319
|
+
external_session_id=external_session_id,
|
|
320
|
+
event=kind,
|
|
321
|
+
)
|
|
322
|
+
return AcpReductionResult(timeline_items=[item])
|
|
323
|
+
else:
|
|
324
|
+
role = "assistant"
|
|
325
|
+
item_type = "message"
|
|
326
|
+
status = "running"
|
|
327
|
+
|
|
328
|
+
item = self._upsert(
|
|
329
|
+
session_id=session_id,
|
|
330
|
+
turn_id=turn_id,
|
|
331
|
+
item_id=message_id,
|
|
332
|
+
item_type=item_type,
|
|
333
|
+
status=status,
|
|
334
|
+
role=role,
|
|
335
|
+
content={"text": text, "format": "markdown"},
|
|
336
|
+
external_session_id=external_session_id,
|
|
337
|
+
event=kind,
|
|
338
|
+
)
|
|
339
|
+
return AcpReductionResult(timeline_items=[item])
|
|
340
|
+
|
|
341
|
+
def _reduce_tool_call(
|
|
342
|
+
self,
|
|
343
|
+
*,
|
|
344
|
+
session_id: str,
|
|
345
|
+
turn_id: str,
|
|
346
|
+
external_session_id: str | None,
|
|
347
|
+
update: dict[str, Any],
|
|
348
|
+
is_update: bool,
|
|
349
|
+
) -> AcpReductionResult:
|
|
350
|
+
tool_call_id = str(update.get("toolCallId") or update.get("tool_call_id") or "tool")
|
|
351
|
+
prev = self._tool_state.get(tool_call_id, {})
|
|
352
|
+
status = _tool_status(update.get("status") or prev.get("status") or "pending")
|
|
353
|
+
title = update.get("title") if update.get("title") is not None else prev.get("title")
|
|
354
|
+
kind = update.get("kind") if update.get("kind") is not None else prev.get("kind")
|
|
355
|
+
raw_input = update.get("rawInput") if "rawInput" in update else update.get("raw_input", prev.get("rawInput"))
|
|
356
|
+
raw_output = update.get("rawOutput") if "rawOutput" in update else update.get("raw_output", prev.get("rawOutput"))
|
|
357
|
+
content_blocks = update.get("content") if isinstance(update.get("content"), list) else prev.get("contentBlocks")
|
|
358
|
+
preview = _tool_content_preview(content_blocks)
|
|
359
|
+
state = {
|
|
360
|
+
"title": title,
|
|
361
|
+
"kind": kind,
|
|
362
|
+
"status": status,
|
|
363
|
+
"rawInput": raw_input,
|
|
364
|
+
"rawOutput": raw_output,
|
|
365
|
+
"contentBlocks": content_blocks,
|
|
366
|
+
}
|
|
367
|
+
self._tool_state[tool_call_id] = state
|
|
368
|
+
content: dict[str, Any] = {
|
|
369
|
+
"toolCallId": tool_call_id,
|
|
370
|
+
"toolName": title or kind or "tool",
|
|
371
|
+
"title": title,
|
|
372
|
+
"kind": _map_tool_kind(kind),
|
|
373
|
+
"status": status,
|
|
374
|
+
}
|
|
375
|
+
if isinstance(raw_input, dict):
|
|
376
|
+
content["rawInput"] = raw_input
|
|
377
|
+
if isinstance(raw_input.get("command"), str):
|
|
378
|
+
content["command"] = raw_input["command"]
|
|
379
|
+
content["kind"] = "command"
|
|
380
|
+
if raw_output is not None:
|
|
381
|
+
content["rawOutput"] = raw_output
|
|
382
|
+
content["result"] = _stringify_output(raw_output)
|
|
383
|
+
content["outputPreview"] = content["result"][:2000]
|
|
384
|
+
if preview:
|
|
385
|
+
content["outputPreview"] = preview[:2000]
|
|
386
|
+
content.setdefault("result", preview)
|
|
387
|
+
item = self._upsert(
|
|
388
|
+
session_id=session_id,
|
|
389
|
+
turn_id=turn_id,
|
|
390
|
+
item_id=tool_call_id,
|
|
391
|
+
item_type="tool",
|
|
392
|
+
status=status,
|
|
393
|
+
role="tool",
|
|
394
|
+
content=content,
|
|
395
|
+
external_session_id=external_session_id,
|
|
396
|
+
event="tool_call_update" if is_update else "tool_call",
|
|
397
|
+
completed_at=utc_now() if status in {"done", "failed", "cancelled"} else None,
|
|
398
|
+
)
|
|
399
|
+
return AcpReductionResult(timeline_items=[item])
|
|
400
|
+
|
|
401
|
+
def _upsert(
|
|
402
|
+
self,
|
|
403
|
+
*,
|
|
404
|
+
session_id: str,
|
|
405
|
+
turn_id: str | None,
|
|
406
|
+
item_id: str,
|
|
407
|
+
item_type: str,
|
|
408
|
+
status: str,
|
|
409
|
+
role: str | None,
|
|
410
|
+
content: dict[str, Any],
|
|
411
|
+
external_session_id: str | None,
|
|
412
|
+
event: str | None,
|
|
413
|
+
source_extra: dict[str, Any] | None = None,
|
|
414
|
+
completed_at: str | None = None,
|
|
415
|
+
) -> dict[str, Any]:
|
|
416
|
+
timeline_id = _timeline_id(session_id, self.runtime, external_session_id, turn_id, item_id)
|
|
417
|
+
order_seq = self._order_by_item.setdefault(timeline_id, self._allocate_order_seq())
|
|
418
|
+
existing = self._items.get(timeline_id)
|
|
419
|
+
revision = int(existing.get("revision", 0)) + 1 if existing else 1
|
|
420
|
+
now = utc_now()
|
|
421
|
+
source: dict[str, Any] = {
|
|
422
|
+
"runtime": self.runtime,
|
|
423
|
+
"sessionId": external_session_id,
|
|
424
|
+
"turnId": turn_id,
|
|
425
|
+
"itemId": item_id,
|
|
426
|
+
"itemType": item_type,
|
|
427
|
+
"event": event,
|
|
428
|
+
}
|
|
429
|
+
if source_extra:
|
|
430
|
+
source.update(source_extra)
|
|
431
|
+
source = {key: value for key, value in source.items() if value is not None}
|
|
432
|
+
content_hash = _content_hash(item_type, status, role, content, source)
|
|
433
|
+
if existing and existing.get("contentHash") == content_hash:
|
|
434
|
+
return existing
|
|
435
|
+
snapshot: dict[str, Any] = {
|
|
436
|
+
"id": timeline_id,
|
|
437
|
+
"sessionId": session_id,
|
|
438
|
+
"turnId": turn_id,
|
|
439
|
+
"type": item_type,
|
|
440
|
+
"status": status,
|
|
441
|
+
"role": role,
|
|
442
|
+
"content": content,
|
|
443
|
+
"source": source,
|
|
444
|
+
"orderSeq": order_seq,
|
|
445
|
+
"revision": revision,
|
|
446
|
+
"contentHash": content_hash,
|
|
447
|
+
"createdAt": existing.get("createdAt") if existing else now,
|
|
448
|
+
"updatedAt": now,
|
|
449
|
+
"completedAt": completed_at,
|
|
450
|
+
}
|
|
451
|
+
if role is None:
|
|
452
|
+
snapshot.pop("role", None)
|
|
453
|
+
if turn_id is None:
|
|
454
|
+
snapshot.pop("turnId", None)
|
|
455
|
+
if completed_at is None:
|
|
456
|
+
snapshot.pop("completedAt", None)
|
|
457
|
+
self._items[timeline_id] = snapshot
|
|
458
|
+
return snapshot
|
|
459
|
+
|
|
460
|
+
def _allocate_order_seq(self) -> int:
|
|
461
|
+
value = self._next_order
|
|
462
|
+
self._next_order += 1
|
|
463
|
+
return value
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def map_approval_status_to_option(
|
|
467
|
+
status: str,
|
|
468
|
+
options: list[dict[str, Any]] | None,
|
|
469
|
+
) -> str | None:
|
|
470
|
+
"""Map AA approval status to an ACP permission optionId."""
|
|
471
|
+
options = options or []
|
|
472
|
+
if status == "approved":
|
|
473
|
+
preferred_ids = ("allow-once", "allow_once", "allow-always", "allow_always")
|
|
474
|
+
preferred_kinds = {"allow_once", "allow_always"}
|
|
475
|
+
elif status == "approved_for_session":
|
|
476
|
+
preferred_ids = ("allow-always", "allow_always", "allow-once", "allow_once")
|
|
477
|
+
preferred_kinds = {"allow_always", "allow_once"}
|
|
478
|
+
elif status == "rejected":
|
|
479
|
+
preferred_ids = ("reject-once", "reject_once", "reject-always", "reject_always")
|
|
480
|
+
preferred_kinds = {"reject_once", "reject_always"}
|
|
481
|
+
else:
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
by_id = {
|
|
485
|
+
str(opt.get("optionId") or opt.get("option_id") or ""): opt
|
|
486
|
+
for opt in options
|
|
487
|
+
if isinstance(opt, dict)
|
|
488
|
+
}
|
|
489
|
+
for candidate in preferred_ids:
|
|
490
|
+
if candidate in by_id and candidate:
|
|
491
|
+
return candidate
|
|
492
|
+
for opt in options:
|
|
493
|
+
if not isinstance(opt, dict):
|
|
494
|
+
continue
|
|
495
|
+
kind = str(opt.get("kind") or "").replace("-", "_")
|
|
496
|
+
option_id = str(opt.get("optionId") or opt.get("option_id") or "")
|
|
497
|
+
if kind in preferred_kinds and option_id:
|
|
498
|
+
return option_id
|
|
499
|
+
return None
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _stop_reason_to_status(stop_reason: str | None, *, interrupted: bool) -> tuple[str, str]:
|
|
503
|
+
if interrupted or stop_reason == "cancelled":
|
|
504
|
+
return "interrupted", "interrupted"
|
|
505
|
+
if stop_reason in {"refusal", "max_tokens", "max_turn_requests"}:
|
|
506
|
+
return "failed", stop_reason or "failed"
|
|
507
|
+
if stop_reason in {"error", "failed"}:
|
|
508
|
+
return "failed", "failed"
|
|
509
|
+
return "done", "completed"
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _tool_status(raw: Any) -> str:
|
|
513
|
+
text = str(raw or "pending")
|
|
514
|
+
mapping = {
|
|
515
|
+
"pending": "pending",
|
|
516
|
+
"in_progress": "running",
|
|
517
|
+
"in-progress": "running",
|
|
518
|
+
"completed": "done",
|
|
519
|
+
"failed": "failed",
|
|
520
|
+
"cancelled": "cancelled",
|
|
521
|
+
"canceled": "cancelled",
|
|
522
|
+
}
|
|
523
|
+
return mapping.get(text, "running" if text else "pending")
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _map_tool_kind(kind: Any) -> str:
|
|
527
|
+
text = str(kind or "other")
|
|
528
|
+
if text in {"read", "search", "fetch", "think"}:
|
|
529
|
+
return "read"
|
|
530
|
+
if text in {"edit", "delete", "move"}:
|
|
531
|
+
return "edit"
|
|
532
|
+
if text in {"execute"}:
|
|
533
|
+
return "command"
|
|
534
|
+
return "other"
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _tool_kind_to_approval_kind(kind: Any) -> str:
|
|
538
|
+
text = str(kind or "")
|
|
539
|
+
if text == "execute":
|
|
540
|
+
return "command"
|
|
541
|
+
if text in {"edit", "delete", "move"}:
|
|
542
|
+
return "file_change"
|
|
543
|
+
return "tool_call"
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _permission_choices(options: list[Any]) -> list[str]:
|
|
547
|
+
choices: list[str] = []
|
|
548
|
+
for opt in options:
|
|
549
|
+
if not isinstance(opt, dict):
|
|
550
|
+
continue
|
|
551
|
+
kind = str(opt.get("kind") or "").replace("-", "_")
|
|
552
|
+
if kind in {"allow_once"} and "approve" not in choices:
|
|
553
|
+
choices.append("approve")
|
|
554
|
+
elif kind in {"allow_always"} and "approve_for_session" not in choices:
|
|
555
|
+
choices.append("approve_for_session")
|
|
556
|
+
elif kind in {"reject_once", "reject_always"} and "reject" not in choices:
|
|
557
|
+
choices.append("reject")
|
|
558
|
+
if not choices:
|
|
559
|
+
choices = ["approve", "reject"]
|
|
560
|
+
if "cancel" not in choices:
|
|
561
|
+
choices.append("cancel")
|
|
562
|
+
return choices
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _permission_description(options: list[Any]) -> str | None:
|
|
566
|
+
labels = []
|
|
567
|
+
for opt in options:
|
|
568
|
+
if isinstance(opt, dict) and opt.get("name"):
|
|
569
|
+
labels.append(str(opt["name"]))
|
|
570
|
+
return ", ".join(labels) if labels else None
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _tool_content_preview(blocks: Any) -> str:
|
|
574
|
+
if not isinstance(blocks, list):
|
|
575
|
+
return ""
|
|
576
|
+
parts: list[str] = []
|
|
577
|
+
for block in blocks:
|
|
578
|
+
if not isinstance(block, dict):
|
|
579
|
+
continue
|
|
580
|
+
if block.get("type") == "content" and isinstance(block.get("content"), dict):
|
|
581
|
+
text = block["content"].get("text")
|
|
582
|
+
if isinstance(text, str):
|
|
583
|
+
parts.append(text)
|
|
584
|
+
elif isinstance(block.get("text"), str):
|
|
585
|
+
parts.append(block["text"])
|
|
586
|
+
return "\n".join(parts)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _stringify_output(value: Any) -> str:
|
|
590
|
+
if isinstance(value, str):
|
|
591
|
+
return value
|
|
592
|
+
try:
|
|
593
|
+
return json.dumps(value, ensure_ascii=False)
|
|
594
|
+
except TypeError:
|
|
595
|
+
return str(value)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _timeline_id(
|
|
599
|
+
session_id: str,
|
|
600
|
+
runtime: str,
|
|
601
|
+
external_session_id: str | None,
|
|
602
|
+
turn_id: str | None,
|
|
603
|
+
item_id: str | None,
|
|
604
|
+
) -> str:
|
|
605
|
+
identity = [session_id, runtime, external_session_id, turn_id, item_id]
|
|
606
|
+
return f"tl_{_short_hash(identity)}"
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _content_hash(*values: Any) -> str:
|
|
610
|
+
return f"sha256:{_short_hash(values, length=64)}"
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _short_hash(value: Any, *, length: int = 20) -> str:
|
|
614
|
+
raw = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
|
|
615
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:length]
|