agentshim 0.2.0__tar.gz → 0.3.1__tar.gz
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.
- {agentshim-0.2.0 → agentshim-0.3.1}/PKG-INFO +1 -1
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/__init__.py +2 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/claude/agent.py +7 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/claude/events.py +11 -2
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/cli_agent.py +5 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/codex/agent.py +72 -13
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/codex/events.py +37 -15
- agentshim-0.3.1/agentshim/copilot/__init__.py +3 -0
- agentshim-0.3.1/agentshim/copilot/agent.py +290 -0
- agentshim-0.3.1/agentshim/copilot/events.py +289 -0
- agentshim-0.3.1/agentshim/copilot_events.py +29 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/events.py +4 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/pyproject.toml +1 -1
- agentshim-0.3.1/tests/fixtures/copilot/session_turn_1.jsonl +11 -0
- agentshim-0.3.1/tests/fixtures/copilot/session_turn_2_resumed.jsonl +11 -0
- agentshim-0.3.1/tests/fixtures/copilot/streaming_dedup.jsonl +5 -0
- agentshim-0.3.1/tests/fixtures/copilot/tool_and_usage.jsonl +6 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/cli_agent/test_cli_prompt_passing.py +26 -1
- {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_claude.py +66 -0
- agentshim-0.3.1/tests/unit/test_agent_cli_codex.py +243 -0
- agentshim-0.3.1/tests/unit/test_agent_cli_copilot.py +168 -0
- agentshim-0.3.1/tests/unit/test_agent_cli_copilot_fixtures.py +99 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_event_parsing.py +127 -4
- {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_resume.py +59 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/test_cli_agent_usage.py +74 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/uv.lock +1 -1
- agentshim-0.2.0/tests/unit/test_agent_cli_codex.py +0 -69
- {agentshim-0.2.0 → agentshim-0.3.1}/.github/workflows/ci.yml +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/.github/workflows/publish.yml +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/.gitignore +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/README.md +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/base.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/claude/__init__.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/claude/hooks/__init__.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/claude/hooks/confine_reads.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/claude_events.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/codex/__init__.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/codex_events.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/gemini/__init__.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/gemini/agent.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/gemini/events.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/gemini_events.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/llm_client.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/mcp_config.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/opencode/__init__.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/opencode/agent.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/opencode/events.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/opencode_events.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/py.typed +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/sandbox.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/subagent.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/trajectory.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/usage.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/utils.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/cli_agent/test_agent_cli_cleanup.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/cli_agent/test_check_cli.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/llm/conftest.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/llm/test_claude_stream.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/llm/test_gemini_fixture.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/llm/test_gemini_stream.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_mcp_unsupported.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_sandbox.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/test_coding_agent_facade.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/test_coding_agent_recorder_default.py +0 -0
- {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/test_mcp_config.py +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from .base import BaseAgentSession, BaseCodingAgent, CodingAgent, get_provider_class, list_providers, register_provider
|
|
2
2
|
from .claude import ClaudeCodeCodingAgent
|
|
3
|
+
from .copilot import CopilotCodingAgent
|
|
3
4
|
from .codex import CodexCodingAgent
|
|
4
5
|
from .gemini import GeminiCodingAgent
|
|
5
6
|
from .mcp_config import HttpMcpServer, McpServerConfig, StdioMcpServer
|
|
@@ -13,6 +14,7 @@ __all__ = [
|
|
|
13
14
|
"get_provider_class",
|
|
14
15
|
"list_providers",
|
|
15
16
|
"register_provider",
|
|
17
|
+
"CopilotCodingAgent",
|
|
16
18
|
"CodexCodingAgent",
|
|
17
19
|
"GeminiCodingAgent",
|
|
18
20
|
"OpencodeCodingAgent",
|
|
@@ -53,6 +53,10 @@ class ClaudeGenerationSession(CLIGenerationSession):
|
|
|
53
53
|
def _handle_event(self, event: ClaudeEvent):
|
|
54
54
|
"""Handle a single parsed Claude event."""
|
|
55
55
|
if isinstance(event, MultiEvent):
|
|
56
|
+
if event.usage and self.event_handler is not None:
|
|
57
|
+
on_usage = getattr(self.event_handler, "on_usage", None)
|
|
58
|
+
if on_usage is not None:
|
|
59
|
+
on_usage(event.usage)
|
|
56
60
|
for sub_event in event.events:
|
|
57
61
|
self._handle_event(sub_event)
|
|
58
62
|
return
|
|
@@ -105,6 +109,9 @@ class ClaudeGenerationSession(CLIGenerationSession):
|
|
|
105
109
|
|
|
106
110
|
elif isinstance(event, ResultEvent):
|
|
107
111
|
self.final_result = event.result
|
|
112
|
+
self.final_usage = event.usage
|
|
113
|
+
self.total_cost_usd = event.total_cost_usd
|
|
114
|
+
self.duration_ms = event.duration_ms
|
|
108
115
|
# Anthropic reports cache_creation + cache_read as disjoint
|
|
109
116
|
# from input_tokens; fold them into input_tokens to match the
|
|
110
117
|
# crucible invariant (cached ⊆ input).
|
|
@@ -36,7 +36,8 @@ class ClaudeEvent(ABC):
|
|
|
36
36
|
parameters=block.get("input"),
|
|
37
37
|
)
|
|
38
38
|
)
|
|
39
|
-
|
|
39
|
+
usage = message.get("usage")
|
|
40
|
+
return MultiEvent(events, usage=usage) if events else None
|
|
40
41
|
if event_type == "user":
|
|
41
42
|
message = data.get("message", {})
|
|
42
43
|
content_blocks = message.get("content", [])
|
|
@@ -53,6 +54,7 @@ class ClaudeEvent(ABC):
|
|
|
53
54
|
num_turns=data.get("num_turns"),
|
|
54
55
|
usage=data.get("usage"),
|
|
55
56
|
total_cost_usd=data.get("total_cost_usd"),
|
|
57
|
+
duration_ms=data.get("duration_ms"),
|
|
56
58
|
)
|
|
57
59
|
|
|
58
60
|
return None
|
|
@@ -61,8 +63,13 @@ class ClaudeEvent(ABC):
|
|
|
61
63
|
class MultiEvent(ClaudeEvent):
|
|
62
64
|
"""Container for multiple events from a single message."""
|
|
63
65
|
|
|
64
|
-
def __init__(
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
events: list[ClaudeEvent],
|
|
69
|
+
usage: dict[str, Any] | None = None,
|
|
70
|
+
):
|
|
65
71
|
self.events = events
|
|
72
|
+
self.usage = usage
|
|
66
73
|
|
|
67
74
|
def render(self, log_prefix: str) -> str | None:
|
|
68
75
|
# MultiEvent doesn't render itself; events are handled individually
|
|
@@ -138,11 +145,13 @@ class ResultEvent(ClaudeEvent):
|
|
|
138
145
|
num_turns: int | None = None,
|
|
139
146
|
usage: dict[str, Any] | None = None,
|
|
140
147
|
total_cost_usd: float | None = None,
|
|
148
|
+
duration_ms: int | None = None,
|
|
141
149
|
):
|
|
142
150
|
self.result = result
|
|
143
151
|
self.num_turns = num_turns
|
|
144
152
|
self.usage = usage
|
|
145
153
|
self.total_cost_usd = total_cost_usd
|
|
154
|
+
self.duration_ms = duration_ms
|
|
146
155
|
|
|
147
156
|
def render(self, log_prefix: str) -> str | None:
|
|
148
157
|
# Result events are silent (result is captured separately)
|
|
@@ -56,6 +56,11 @@ class CLIGenerationSession:
|
|
|
56
56
|
# Providers populate this during event handling; stays at the
|
|
57
57
|
# empty default if the session crashes before any terminal event.
|
|
58
58
|
self.usage: ProviderUsage = ProviderUsage()
|
|
59
|
+
# Final provider-reported accounting snapshot, when the CLI exposes
|
|
60
|
+
# one on a terminal event.
|
|
61
|
+
self.final_usage: dict[str, Any] | None = None
|
|
62
|
+
self.total_cost_usd: float | None = None
|
|
63
|
+
self.duration_ms: int | None = None
|
|
59
64
|
# Provider session id captured from the event stream (set by subclasses
|
|
60
65
|
# that parse JSON events). ``None`` if the underlying CLI did not emit
|
|
61
66
|
# an id during this run.
|
|
@@ -15,6 +15,7 @@ from ..usage import ProviderUsage, TokenUsage
|
|
|
15
15
|
from .events import (
|
|
16
16
|
CodexEvent,
|
|
17
17
|
ErrorEvent,
|
|
18
|
+
LifecycleEvent,
|
|
18
19
|
TextEvent,
|
|
19
20
|
ThreadStartedEvent,
|
|
20
21
|
ToolResultEvent,
|
|
@@ -38,21 +39,28 @@ class CodexGenerationSession(CLIGenerationSession):
|
|
|
38
39
|
self._accumulated_tokens = TokenUsage()
|
|
39
40
|
|
|
40
41
|
def _process_stdout(self, line: str) -> None:
|
|
41
|
-
if not line:
|
|
42
|
+
if not line.strip():
|
|
42
43
|
return
|
|
43
44
|
try:
|
|
44
45
|
data = json.loads(line)
|
|
45
46
|
except json.JSONDecodeError:
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
stripped = line.rstrip()
|
|
48
|
+
self.stdout_lines.append(stripped)
|
|
49
|
+
if self.event_handler and stripped:
|
|
50
|
+
self.event_handler.on_thinking(stripped)
|
|
51
|
+
elif not self.silent:
|
|
48
52
|
if self._at_line_start:
|
|
49
53
|
self._log_raw(f"{self.log_prefix} ")
|
|
50
|
-
self._log_raw(
|
|
54
|
+
self._log_raw(stripped + "\n")
|
|
51
55
|
self._at_line_start = True
|
|
52
56
|
return
|
|
53
57
|
|
|
54
58
|
event = CodexEvent.from_dict(data)
|
|
55
59
|
if event is None:
|
|
60
|
+
stripped = line.rstrip()
|
|
61
|
+
if self.event_handler and stripped:
|
|
62
|
+
self.event_handler.on_thinking(stripped)
|
|
63
|
+
self.stdout_lines.append(stripped)
|
|
56
64
|
return
|
|
57
65
|
self._handle_event(event)
|
|
58
66
|
|
|
@@ -65,6 +73,13 @@ class CodexGenerationSession(CLIGenerationSession):
|
|
|
65
73
|
if isinstance(event, ThreadStartedEvent):
|
|
66
74
|
if self.session_id is None and event.thread_id:
|
|
67
75
|
self.session_id = event.thread_id
|
|
76
|
+
if self.event_handler and event.thread_id:
|
|
77
|
+
self.event_handler.on_thinking(f"[codex thread {event.thread_id} started]")
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
if isinstance(event, LifecycleEvent):
|
|
81
|
+
if self.event_handler and event.event_type == "turn.started":
|
|
82
|
+
self.event_handler.on_thinking("[codex turn started]")
|
|
68
83
|
return
|
|
69
84
|
|
|
70
85
|
if isinstance(event, TextEvent):
|
|
@@ -76,12 +91,12 @@ class CodexGenerationSession(CLIGenerationSession):
|
|
|
76
91
|
return
|
|
77
92
|
|
|
78
93
|
if isinstance(event, ToolUseEvent):
|
|
79
|
-
if event.tool_id:
|
|
94
|
+
if event.tool_name == "execute" and event.tool_id:
|
|
80
95
|
self.tool_map[event.tool_id] = event.tool_name
|
|
81
96
|
self.tool_start_times[event.tool_id] = time.time()
|
|
82
97
|
self.tool_args[event.tool_id] = event.parameters
|
|
83
|
-
|
|
84
|
-
|
|
98
|
+
if self.event_handler:
|
|
99
|
+
self.event_handler.on_tool_call(event.tool_name, event.parameters)
|
|
85
100
|
return
|
|
86
101
|
|
|
87
102
|
if isinstance(event, TurnCompletedEvent):
|
|
@@ -96,16 +111,48 @@ class CodexGenerationSession(CLIGenerationSession):
|
|
|
96
111
|
total_cost_usd=None,
|
|
97
112
|
provider="codex",
|
|
98
113
|
)
|
|
114
|
+
normalized_usage = {
|
|
115
|
+
"input_tokens": event.input_tokens,
|
|
116
|
+
"output_tokens": event.output_tokens,
|
|
117
|
+
"cache_read_input_tokens": event.cached_input_tokens,
|
|
118
|
+
"cache_creation_input_tokens": 0,
|
|
119
|
+
}
|
|
120
|
+
if event.has_usage:
|
|
121
|
+
self.final_usage = normalized_usage
|
|
122
|
+
if self.event_handler is not None and event.has_usage:
|
|
123
|
+
on_usage = getattr(self.event_handler, "on_usage", None)
|
|
124
|
+
if on_usage is not None:
|
|
125
|
+
on_usage(normalized_usage)
|
|
126
|
+
self.event_handler.on_thinking(
|
|
127
|
+
f"[codex turn complete: in={normalized_usage['input_tokens']} "
|
|
128
|
+
f"cached={normalized_usage['cache_read_input_tokens']} "
|
|
129
|
+
f"out={normalized_usage['output_tokens']}]"
|
|
130
|
+
)
|
|
131
|
+
elif self.event_handler is not None:
|
|
132
|
+
self.event_handler.on_thinking("[codex turn complete]")
|
|
99
133
|
return
|
|
100
134
|
|
|
101
135
|
if isinstance(event, ToolResultEvent):
|
|
102
136
|
if not event.tool_id:
|
|
103
|
-
|
|
104
|
-
|
|
137
|
+
if event.tool_name is None:
|
|
138
|
+
return
|
|
139
|
+
event.tool_name_resolved = event.tool_name
|
|
140
|
+
args = event.parameters or {}
|
|
141
|
+
duration = None
|
|
142
|
+
if self.event_handler:
|
|
143
|
+
self.event_handler.on_tool_call(event.tool_name, args)
|
|
144
|
+
else:
|
|
145
|
+
event.tool_name_resolved = self.tool_map.get(
|
|
146
|
+
event.tool_id,
|
|
147
|
+
event.tool_name or "Tool",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
start_time = self.tool_start_times.get(event.tool_id)
|
|
151
|
+
duration = time.time() - start_time if start_time else None
|
|
152
|
+
args = self.tool_args.get(event.tool_id, event.parameters or {})
|
|
105
153
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
args = self.tool_args.get(event.tool_id, {})
|
|
154
|
+
if event.tool_id not in self.tool_map and self.event_handler and event.tool_name:
|
|
155
|
+
self.event_handler.on_tool_call(event.tool_name, args)
|
|
109
156
|
|
|
110
157
|
self.recorder.add_tool_call(
|
|
111
158
|
tool=event.tool_name_resolved,
|
|
@@ -140,8 +187,20 @@ class CodexGenerationSession(CLIGenerationSession):
|
|
|
140
187
|
if isinstance(event, ErrorEvent):
|
|
141
188
|
self.stdout_lines.append(event.message)
|
|
142
189
|
|
|
190
|
+
def _process_stderr(self, line: str) -> None:
|
|
191
|
+
line_stripped = line.rstrip("\n")
|
|
192
|
+
self.stderr_lines.append(line)
|
|
193
|
+
if self.event_handler and line_stripped:
|
|
194
|
+
self.event_handler.on_thinking(f"[codex stderr] {line_stripped}")
|
|
195
|
+
elif not self.silent:
|
|
196
|
+
self.logger.bind(stderr=True).info(f"[STDERR] {line_stripped}")
|
|
197
|
+
|
|
143
198
|
def run(self, prompt: str) -> str:
|
|
144
|
-
|
|
199
|
+
started = time.monotonic()
|
|
200
|
+
try:
|
|
201
|
+
super().run(prompt)
|
|
202
|
+
finally:
|
|
203
|
+
self.duration_ms = int((time.monotonic() - started) * 1000)
|
|
145
204
|
if self.final_result:
|
|
146
205
|
return self.final_result
|
|
147
206
|
return "\n".join(self.stdout_lines)
|
|
@@ -27,13 +27,7 @@ class CodexEvent(ABC):
|
|
|
27
27
|
return LifecycleEvent(event_type)
|
|
28
28
|
|
|
29
29
|
if event_type == "turn.completed":
|
|
30
|
-
|
|
31
|
-
usage = cast("dict[str, Any]", usage_raw) if isinstance(usage_raw, dict) else {}
|
|
32
|
-
return TurnCompletedEvent(
|
|
33
|
-
input_tokens=int(usage.get("input_tokens") or 0),
|
|
34
|
-
cached_input_tokens=int(usage.get("cached_input_tokens") or 0),
|
|
35
|
-
output_tokens=int(usage.get("output_tokens") or 0),
|
|
36
|
-
)
|
|
30
|
+
return TurnCompletedEvent.from_usage_payload(data.get("usage"))
|
|
37
31
|
|
|
38
32
|
if event_type in ("item.started", "item.completed"):
|
|
39
33
|
item_raw = data.get("item")
|
|
@@ -52,7 +46,15 @@ class CodexEvent(ABC):
|
|
|
52
46
|
text_raw = item.get("text", "")
|
|
53
47
|
return TextEvent(text=text_raw if isinstance(text_raw, str) else "")
|
|
54
48
|
|
|
49
|
+
if item_type == "reasoning":
|
|
50
|
+
if not completed:
|
|
51
|
+
return None
|
|
52
|
+
text_raw = item.get("text", "")
|
|
53
|
+
return TextEvent(text=text_raw if isinstance(text_raw, str) else "")
|
|
54
|
+
|
|
55
55
|
if item_type == "command_execution":
|
|
56
|
+
command_raw = item.get("command", "")
|
|
57
|
+
command = command_raw if isinstance(command_raw, str) else ""
|
|
56
58
|
exit_code_raw = item.get("exit_code")
|
|
57
59
|
exit_code = exit_code_raw if isinstance(exit_code_raw, int) else None
|
|
58
60
|
status_raw = item.get("status")
|
|
@@ -63,23 +65,22 @@ class CodexEvent(ABC):
|
|
|
63
65
|
output=item.get("aggregated_output", ""),
|
|
64
66
|
exit_code=exit_code,
|
|
65
67
|
status=status,
|
|
68
|
+
tool_name="execute",
|
|
69
|
+
parameters={"command": command},
|
|
66
70
|
)
|
|
67
|
-
command_raw = item.get("command", "")
|
|
68
|
-
command = command_raw if isinstance(command_raw, str) else ""
|
|
69
71
|
return ToolUseEvent(
|
|
70
72
|
tool_id=item_id,
|
|
71
|
-
tool_name="
|
|
73
|
+
tool_name="execute",
|
|
72
74
|
parameters={"command": command},
|
|
73
75
|
)
|
|
74
76
|
|
|
75
77
|
status_raw = item.get("status")
|
|
76
78
|
status = status_raw if isinstance(status_raw, str) else None
|
|
77
79
|
if completed:
|
|
78
|
-
return
|
|
80
|
+
return ToolUseEvent(
|
|
79
81
|
tool_id=item_id,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
status=status,
|
|
82
|
+
tool_name=item_type or "item",
|
|
83
|
+
parameters=_item_parameters(item),
|
|
83
84
|
)
|
|
84
85
|
return ToolUseEvent(
|
|
85
86
|
tool_id=item_id,
|
|
@@ -171,6 +172,8 @@ class ToolResultEvent(CodexEvent):
|
|
|
171
172
|
tool_id: str | None,
|
|
172
173
|
exit_code: int | None = None,
|
|
173
174
|
status: str | None = None,
|
|
175
|
+
tool_name: str | None = None,
|
|
176
|
+
parameters: Any = None,
|
|
174
177
|
):
|
|
175
178
|
if isinstance(output, list):
|
|
176
179
|
items = cast("list[Any]", output)
|
|
@@ -180,7 +183,9 @@ class ToolResultEvent(CodexEvent):
|
|
|
180
183
|
self.tool_id = tool_id
|
|
181
184
|
self.exit_code = exit_code
|
|
182
185
|
self.status = status
|
|
183
|
-
self.
|
|
186
|
+
self.tool_name = tool_name
|
|
187
|
+
self.parameters = parameters
|
|
188
|
+
self.tool_name_resolved: str = tool_name or "Tool"
|
|
184
189
|
|
|
185
190
|
def render(self, log_prefix: str) -> str:
|
|
186
191
|
if not self.output:
|
|
@@ -197,10 +202,27 @@ class TurnCompletedEvent(CodexEvent):
|
|
|
197
202
|
input_tokens: int = 0,
|
|
198
203
|
cached_input_tokens: int = 0,
|
|
199
204
|
output_tokens: int = 0,
|
|
205
|
+
usage: dict[str, Any] | None = None,
|
|
200
206
|
):
|
|
201
207
|
self.input_tokens = input_tokens
|
|
202
208
|
self.cached_input_tokens = cached_input_tokens
|
|
203
209
|
self.output_tokens = output_tokens
|
|
210
|
+
self.usage = usage
|
|
211
|
+
|
|
212
|
+
@classmethod
|
|
213
|
+
def from_usage_payload(cls, usage_raw: Any) -> TurnCompletedEvent:
|
|
214
|
+
usage = cast("dict[str, Any]", usage_raw) if isinstance(usage_raw, dict) else None
|
|
215
|
+
usage_dict = usage or {}
|
|
216
|
+
return cls(
|
|
217
|
+
input_tokens=int(usage_dict.get("input_tokens") or 0),
|
|
218
|
+
cached_input_tokens=int(usage_dict.get("cached_input_tokens") or 0),
|
|
219
|
+
output_tokens=int(usage_dict.get("output_tokens") or 0),
|
|
220
|
+
usage=usage,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def has_usage(self) -> bool:
|
|
225
|
+
return self.usage is not None
|
|
204
226
|
|
|
205
227
|
def render(self, log_prefix: str) -> str | None:
|
|
206
228
|
return None
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from agentshim.trajectory import TrajectoryRecorderProtocol
|
|
10
|
+
|
|
11
|
+
from ..base import register_provider
|
|
12
|
+
from ..cli_agent import CLICodingAgent, CLIGenerationSession
|
|
13
|
+
from ..events import AgentEventHandler
|
|
14
|
+
from ..mcp_config import HttpMcpServer, McpServerConfig
|
|
15
|
+
from ..sandbox import SandboxConfig
|
|
16
|
+
from ..usage import ProviderUsage, TokenUsage
|
|
17
|
+
from .events import (
|
|
18
|
+
CopilotEvent,
|
|
19
|
+
ErrorEvent,
|
|
20
|
+
IntentEvent,
|
|
21
|
+
MessageDeltaEvent,
|
|
22
|
+
MessageEvent,
|
|
23
|
+
ResultEvent,
|
|
24
|
+
SessionStartEvent,
|
|
25
|
+
ToolResultEvent,
|
|
26
|
+
ToolUseEvent,
|
|
27
|
+
TurnEndEvent,
|
|
28
|
+
UsageEvent,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CopilotGenerationSession(CLIGenerationSession):
|
|
33
|
+
"""Session that parses Copilot CLI JSONL events."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, **kwargs: Any):
|
|
36
|
+
super().__init__(**kwargs)
|
|
37
|
+
self.tool_map: dict[str, str] = {}
|
|
38
|
+
self.tool_start_times: dict[str, float] = {}
|
|
39
|
+
self.tool_args: dict[str, Any] = {}
|
|
40
|
+
self.final_result: str | None = None
|
|
41
|
+
self._seen_message_deltas: set[str] = set()
|
|
42
|
+
self._streamed_text_chunks: list[str] = []
|
|
43
|
+
self._accumulated_tokens = TokenUsage()
|
|
44
|
+
self._turn_count = 0
|
|
45
|
+
self._message_output_tokens = 0
|
|
46
|
+
self._saw_usage_event = False
|
|
47
|
+
|
|
48
|
+
def _refresh_usage(self) -> None:
|
|
49
|
+
effective_output_tokens = self._accumulated_tokens.output_tokens
|
|
50
|
+
if not self._saw_usage_event:
|
|
51
|
+
effective_output_tokens = self._message_output_tokens
|
|
52
|
+
self.usage = ProviderUsage(
|
|
53
|
+
tokens=TokenUsage(
|
|
54
|
+
input_tokens=self._accumulated_tokens.input_tokens,
|
|
55
|
+
output_tokens=effective_output_tokens,
|
|
56
|
+
cached_input_tokens=self._accumulated_tokens.cached_input_tokens,
|
|
57
|
+
turns=self._turn_count,
|
|
58
|
+
),
|
|
59
|
+
total_cost_usd=None,
|
|
60
|
+
provider="copilot",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def _process_stdout(self, line: str) -> None:
|
|
64
|
+
if not line:
|
|
65
|
+
return
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(line)
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
self.stdout_lines.append(line.rstrip())
|
|
70
|
+
if not self.silent:
|
|
71
|
+
if self._at_line_start:
|
|
72
|
+
self._log_raw(f"{self.log_prefix} ")
|
|
73
|
+
self._log_raw(line.rstrip() + "\n")
|
|
74
|
+
self._at_line_start = True
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
if not isinstance(data, dict):
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
event = CopilotEvent.from_dict(data)
|
|
81
|
+
if event is None:
|
|
82
|
+
return
|
|
83
|
+
self._handle_event(event)
|
|
84
|
+
|
|
85
|
+
def _handle_event(self, event: CopilotEvent) -> None:
|
|
86
|
+
self._update_state(event)
|
|
87
|
+
if not self.silent:
|
|
88
|
+
self._render_event(event)
|
|
89
|
+
|
|
90
|
+
def _update_state(self, event: CopilotEvent) -> None:
|
|
91
|
+
if isinstance(event, SessionStartEvent):
|
|
92
|
+
if self.session_id is None and event.session_id:
|
|
93
|
+
self.session_id = event.session_id
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
if isinstance(event, MessageDeltaEvent):
|
|
97
|
+
if event.message_id:
|
|
98
|
+
self._seen_message_deltas.add(event.message_id)
|
|
99
|
+
if event.delta_content:
|
|
100
|
+
self._streamed_text_chunks.append(event.delta_content)
|
|
101
|
+
if self.event_handler:
|
|
102
|
+
self.event_handler.on_thinking(event.delta_content)
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
if isinstance(event, MessageEvent):
|
|
106
|
+
if event.content:
|
|
107
|
+
self.stdout_lines.append(event.content)
|
|
108
|
+
self.final_result = event.content
|
|
109
|
+
if not self._saw_usage_event:
|
|
110
|
+
self._message_output_tokens += event.output_tokens
|
|
111
|
+
self._refresh_usage()
|
|
112
|
+
if self.event_handler:
|
|
113
|
+
self.event_handler.on_thinking(event.content)
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
if isinstance(event, ToolUseEvent):
|
|
117
|
+
if event.tool_id:
|
|
118
|
+
self.tool_map[event.tool_id] = event.tool_name
|
|
119
|
+
self.tool_start_times[event.tool_id] = time.time()
|
|
120
|
+
self.tool_args[event.tool_id] = event.arguments
|
|
121
|
+
if self.event_handler:
|
|
122
|
+
self.event_handler.on_tool_call(event.tool_name, event.arguments)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
if isinstance(event, ToolResultEvent):
|
|
126
|
+
if not event.tool_id:
|
|
127
|
+
return
|
|
128
|
+
event.tool_name_resolved = self.tool_map.get(event.tool_id, "Tool")
|
|
129
|
+
start_time = self.tool_start_times.get(event.tool_id)
|
|
130
|
+
duration = time.time() - start_time if start_time else None
|
|
131
|
+
args = self.tool_args.get(event.tool_id, {})
|
|
132
|
+
stdout = event.output or event.error_message
|
|
133
|
+
|
|
134
|
+
self.recorder.add_tool_call(
|
|
135
|
+
tool=event.tool_name_resolved,
|
|
136
|
+
args=args,
|
|
137
|
+
stdout=stdout,
|
|
138
|
+
exit_code=event.exit_code,
|
|
139
|
+
duration=duration,
|
|
140
|
+
)
|
|
141
|
+
if self.event_handler:
|
|
142
|
+
self.event_handler.on_tool_result(
|
|
143
|
+
tool=event.tool_name_resolved,
|
|
144
|
+
stdout=stdout,
|
|
145
|
+
exit_code=event.exit_code,
|
|
146
|
+
duration=duration,
|
|
147
|
+
)
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
if isinstance(event, UsageEvent):
|
|
151
|
+
self._saw_usage_event = True
|
|
152
|
+
cached = event.cache_read_tokens + event.cache_write_tokens
|
|
153
|
+
self._accumulated_tokens = self._accumulated_tokens + TokenUsage(
|
|
154
|
+
input_tokens=event.input_tokens + cached,
|
|
155
|
+
output_tokens=event.output_tokens + event.reasoning_tokens,
|
|
156
|
+
cached_input_tokens=cached,
|
|
157
|
+
turns=0,
|
|
158
|
+
)
|
|
159
|
+
self._refresh_usage()
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
if isinstance(event, TurnEndEvent):
|
|
163
|
+
self._turn_count += 1
|
|
164
|
+
self._refresh_usage()
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
if isinstance(event, ResultEvent):
|
|
168
|
+
if self.session_id is None and event.session_id:
|
|
169
|
+
self.session_id = event.session_id
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
if isinstance(event, ErrorEvent):
|
|
173
|
+
if event.message:
|
|
174
|
+
self.stdout_lines.append(event.message)
|
|
175
|
+
|
|
176
|
+
def _render_event(self, event: CopilotEvent) -> None:
|
|
177
|
+
if isinstance(event, MessageDeltaEvent):
|
|
178
|
+
if event.delta_content:
|
|
179
|
+
self._print_stream_content(event.delta_content)
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
if isinstance(event, MessageEvent):
|
|
183
|
+
if event.message_id and event.message_id in self._seen_message_deltas:
|
|
184
|
+
return
|
|
185
|
+
if event.content:
|
|
186
|
+
self._print_stream_content(event.content)
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
if not self._at_line_start:
|
|
190
|
+
self._log_raw("\n")
|
|
191
|
+
self._at_line_start = True
|
|
192
|
+
|
|
193
|
+
if isinstance(event, IntentEvent):
|
|
194
|
+
output = event.render(self.log_prefix)
|
|
195
|
+
else:
|
|
196
|
+
output = event.render(self.log_prefix)
|
|
197
|
+
if output:
|
|
198
|
+
self._log_raw(output + "\n")
|
|
199
|
+
|
|
200
|
+
def run(self, prompt: str) -> str:
|
|
201
|
+
super().run(prompt)
|
|
202
|
+
if self.final_result:
|
|
203
|
+
return self.final_result
|
|
204
|
+
if self._streamed_text_chunks:
|
|
205
|
+
return "".join(self._streamed_text_chunks).strip()
|
|
206
|
+
return "\n".join(self.stdout_lines)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@register_provider("copilot", "github-copilot", "copilot-cli")
|
|
210
|
+
class CopilotCodingAgent(CLICodingAgent):
|
|
211
|
+
"""Coding agent implementation using the GitHub Copilot CLI tool."""
|
|
212
|
+
|
|
213
|
+
def __init__(
|
|
214
|
+
self,
|
|
215
|
+
model: str | None = None,
|
|
216
|
+
recorder: TrajectoryRecorderProtocol | None = None,
|
|
217
|
+
event_handler: AgentEventHandler | None = None,
|
|
218
|
+
mcp_servers: list[McpServerConfig] | None = None,
|
|
219
|
+
sandbox: bool | SandboxConfig = False,
|
|
220
|
+
):
|
|
221
|
+
if sandbox:
|
|
222
|
+
raise NotImplementedError("sandbox is not supported for CopilotCodingAgent")
|
|
223
|
+
super().__init__("copilot", model, recorder, event_handler, mcp_servers)
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def copilot_path(self) -> str:
|
|
227
|
+
"""Return path to copilot binary (for backward compatibility)."""
|
|
228
|
+
return self.binary_path
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def _log_prefix(self) -> str:
|
|
232
|
+
return "[Copilot]"
|
|
233
|
+
|
|
234
|
+
def _build_mcp_config_json(self) -> str:
|
|
235
|
+
servers: dict[str, dict[str, Any]] = {}
|
|
236
|
+
for server in self.mcp_servers:
|
|
237
|
+
if isinstance(server, HttpMcpServer):
|
|
238
|
+
entry: dict[str, Any] = {"type": "sse", "url": server.url}
|
|
239
|
+
if server.headers:
|
|
240
|
+
entry["headers"] = dict(server.headers)
|
|
241
|
+
servers[server.name] = entry
|
|
242
|
+
else:
|
|
243
|
+
entry = {"command": server.command, "args": server.args}
|
|
244
|
+
if server.env:
|
|
245
|
+
entry["env"] = server.env
|
|
246
|
+
servers[server.name] = entry
|
|
247
|
+
return json.dumps({"mcpServers": servers})
|
|
248
|
+
|
|
249
|
+
def _get_command(self, prompt: str, resume_session_id: str | None = None) -> list[str]:
|
|
250
|
+
cmd = [
|
|
251
|
+
self.binary_path,
|
|
252
|
+
"--output-format",
|
|
253
|
+
"json",
|
|
254
|
+
"--stream",
|
|
255
|
+
"off",
|
|
256
|
+
"--allow-all-tools",
|
|
257
|
+
"--allow-all-paths",
|
|
258
|
+
"--allow-all-urls",
|
|
259
|
+
]
|
|
260
|
+
if resume_session_id:
|
|
261
|
+
cmd.extend(["--resume", resume_session_id])
|
|
262
|
+
if self.model:
|
|
263
|
+
cmd.extend(["--model", self.model])
|
|
264
|
+
if self.mcp_servers:
|
|
265
|
+
cmd.extend(["--additional-mcp-config", self._build_mcp_config_json()])
|
|
266
|
+
cmd.extend(["-p", prompt])
|
|
267
|
+
return cmd
|
|
268
|
+
|
|
269
|
+
def _create_session(
|
|
270
|
+
self,
|
|
271
|
+
cmd: list[str],
|
|
272
|
+
cwd: str | None = None,
|
|
273
|
+
timeout: int = 300,
|
|
274
|
+
silent: bool = False,
|
|
275
|
+
recorder: TrajectoryRecorderProtocol | None = None,
|
|
276
|
+
on_process_started: Callable[[subprocess.Popen[str]], None] | None = None,
|
|
277
|
+
) -> CopilotGenerationSession:
|
|
278
|
+
return CopilotGenerationSession(
|
|
279
|
+
binary_name=self.binary_name,
|
|
280
|
+
env=self.env,
|
|
281
|
+
log_prefix=self._log_prefix,
|
|
282
|
+
cmd=cmd,
|
|
283
|
+
logger=self.logger,
|
|
284
|
+
cwd=cwd,
|
|
285
|
+
timeout=timeout,
|
|
286
|
+
silent=silent,
|
|
287
|
+
recorder=recorder,
|
|
288
|
+
event_handler=self.event_handler,
|
|
289
|
+
on_process_started=on_process_started,
|
|
290
|
+
)
|