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.
Files changed (65) hide show
  1. {agentshim-0.2.0 → agentshim-0.3.1}/PKG-INFO +1 -1
  2. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/__init__.py +2 -0
  3. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/claude/agent.py +7 -0
  4. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/claude/events.py +11 -2
  5. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/cli_agent.py +5 -0
  6. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/codex/agent.py +72 -13
  7. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/codex/events.py +37 -15
  8. agentshim-0.3.1/agentshim/copilot/__init__.py +3 -0
  9. agentshim-0.3.1/agentshim/copilot/agent.py +290 -0
  10. agentshim-0.3.1/agentshim/copilot/events.py +289 -0
  11. agentshim-0.3.1/agentshim/copilot_events.py +29 -0
  12. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/events.py +4 -0
  13. {agentshim-0.2.0 → agentshim-0.3.1}/pyproject.toml +1 -1
  14. agentshim-0.3.1/tests/fixtures/copilot/session_turn_1.jsonl +11 -0
  15. agentshim-0.3.1/tests/fixtures/copilot/session_turn_2_resumed.jsonl +11 -0
  16. agentshim-0.3.1/tests/fixtures/copilot/streaming_dedup.jsonl +5 -0
  17. agentshim-0.3.1/tests/fixtures/copilot/tool_and_usage.jsonl +6 -0
  18. {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/cli_agent/test_cli_prompt_passing.py +26 -1
  19. {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_claude.py +66 -0
  20. agentshim-0.3.1/tests/unit/test_agent_cli_codex.py +243 -0
  21. agentshim-0.3.1/tests/unit/test_agent_cli_copilot.py +168 -0
  22. agentshim-0.3.1/tests/unit/test_agent_cli_copilot_fixtures.py +99 -0
  23. {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_event_parsing.py +127 -4
  24. {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_resume.py +59 -0
  25. {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/test_cli_agent_usage.py +74 -0
  26. {agentshim-0.2.0 → agentshim-0.3.1}/uv.lock +1 -1
  27. agentshim-0.2.0/tests/unit/test_agent_cli_codex.py +0 -69
  28. {agentshim-0.2.0 → agentshim-0.3.1}/.github/workflows/ci.yml +0 -0
  29. {agentshim-0.2.0 → agentshim-0.3.1}/.github/workflows/publish.yml +0 -0
  30. {agentshim-0.2.0 → agentshim-0.3.1}/.gitignore +0 -0
  31. {agentshim-0.2.0 → agentshim-0.3.1}/README.md +0 -0
  32. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/base.py +0 -0
  33. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/claude/__init__.py +0 -0
  34. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/claude/hooks/__init__.py +0 -0
  35. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/claude/hooks/confine_reads.py +0 -0
  36. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/claude_events.py +0 -0
  37. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/codex/__init__.py +0 -0
  38. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/codex_events.py +0 -0
  39. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/gemini/__init__.py +0 -0
  40. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/gemini/agent.py +0 -0
  41. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/gemini/events.py +0 -0
  42. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/gemini_events.py +0 -0
  43. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/llm_client.py +0 -0
  44. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/mcp_config.py +0 -0
  45. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/opencode/__init__.py +0 -0
  46. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/opencode/agent.py +0 -0
  47. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/opencode/events.py +0 -0
  48. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/opencode_events.py +0 -0
  49. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/py.typed +0 -0
  50. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/sandbox.py +0 -0
  51. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/subagent.py +0 -0
  52. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/trajectory.py +0 -0
  53. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/usage.py +0 -0
  54. {agentshim-0.2.0 → agentshim-0.3.1}/agentshim/utils.py +0 -0
  55. {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/cli_agent/test_agent_cli_cleanup.py +0 -0
  56. {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/cli_agent/test_check_cli.py +0 -0
  57. {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/llm/conftest.py +0 -0
  58. {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/llm/test_claude_stream.py +0 -0
  59. {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/llm/test_gemini_fixture.py +0 -0
  60. {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/llm/test_gemini_stream.py +0 -0
  61. {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_mcp_unsupported.py +0 -0
  62. {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_sandbox.py +0 -0
  63. {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/test_coding_agent_facade.py +0 -0
  64. {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/test_coding_agent_recorder_default.py +0 -0
  65. {agentshim-0.2.0 → agentshim-0.3.1}/tests/unit/test_mcp_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentshim
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Provider-agnostic coding-agent CLI shims
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: litellm>=1.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
- return MultiEvent(events) if events else None
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__(self, events: list[ClaudeEvent]):
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
- self.stdout_lines.append(line.rstrip())
47
- if not self.silent:
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(line.rstrip() + "\n")
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
- if self.event_handler:
84
- self.event_handler.on_tool_call(event.tool_name, event.parameters)
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
- return
104
- event.tool_name_resolved = self.tool_map.get(event.tool_id, "Tool")
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
- start_time = self.tool_start_times.get(event.tool_id)
107
- duration = time.time() - start_time if start_time else None
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
- super().run(prompt)
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
- usage_raw = data.get("usage")
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="shell",
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 ToolResultEvent(
80
+ return ToolUseEvent(
79
81
  tool_id=item_id,
80
- output=_summarize_item(item),
81
- exit_code=None,
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.tool_name_resolved: str = "Tool"
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,3 @@
1
+ from .agent import CopilotCodingAgent, CopilotGenerationSession
2
+
3
+ __all__ = ["CopilotCodingAgent", "CopilotGenerationSession"]
@@ -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
+ )