coding-agent-cli 0.1.0__py3-none-any.whl → 0.2.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.
@@ -1,11 +1,17 @@
1
1
  """Run coding-agent CLIs, stream typed events, and return a classified result."""
2
2
 
3
3
  from .api import EventStream, check, run, run_sync, stream
4
+ from .driver import CliDriver, Facts, Launch, Parser, RequestError
4
5
  from .events import (
5
6
  SCHEMA_VERSION,
6
7
  Event,
8
+ FileChanged,
9
+ JsonValue,
7
10
  MessageCompleted,
8
11
  MessageStarted,
12
+ Notice,
13
+ RawEvent,
14
+ ReasoningDelta,
9
15
  RunCompleted,
10
16
  SessionIdentified,
11
17
  TextDelta,
@@ -13,28 +19,36 @@ from .events import (
13
19
  ToolStarted,
14
20
  UsageUpdated,
15
21
  )
16
- from .registry import Driver, DriverRegistry, EventHandler, SpawnHandler
17
- from .types import Capabilities, Diagnostic, Outcome, Request, Result, Usage
22
+ from .registry import DriverRegistry
23
+ from .types import Capabilities, Diagnostic, ErrorCode, Outcome, Request, Result, Usage
18
24
 
19
- __version__ = "0.1.0"
25
+ __version__ = "0.2.0"
20
26
 
21
27
  __all__ = [
22
28
  "SCHEMA_VERSION",
23
29
  "Capabilities",
24
30
  "Diagnostic",
25
- "Driver",
26
- "EventStream",
31
+ "CliDriver",
27
32
  "DriverRegistry",
33
+ "ErrorCode",
28
34
  "Event",
29
- "EventHandler",
35
+ "EventStream",
36
+ "FileChanged",
37
+ "Facts",
38
+ "JsonValue",
30
39
  "MessageCompleted",
31
40
  "MessageStarted",
41
+ "Launch",
42
+ "Notice",
32
43
  "Outcome",
44
+ "RawEvent",
45
+ "ReasoningDelta",
33
46
  "Request",
34
47
  "Result",
35
48
  "RunCompleted",
36
49
  "SessionIdentified",
37
- "SpawnHandler",
50
+ "Parser",
51
+ "RequestError",
38
52
  "TextDelta",
39
53
  "ToolCompleted",
40
54
  "ToolStarted",
coding_agent_cli/_args.py CHANGED
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from collections.abc import Iterable, Mapping, Sequence
6
6
  from typing import Any
7
7
 
8
- from .types import Diagnostic, Request
8
+ from .types import Diagnostic, ErrorCode, Request
9
9
 
10
10
  PORTABLE_FIELDS = ("model", "reasoning", "system_prompt")
11
11
 
@@ -22,11 +22,21 @@ def _has_flag(args: Sequence[str], flags: Iterable[str]) -> str | None:
22
22
  def validate_args(args: Sequence[Any]) -> Diagnostic | None:
23
23
  """Reject non-string args, NUL bytes, and the reserved bare ``--`` token."""
24
24
  if not all(isinstance(value, str) for value in args):
25
- return Diagnostic(code="invalid_args", message="args must contain only strings")
25
+ return Diagnostic(code=ErrorCode.INVALID_ARGS, message="args must contain only strings")
26
26
  if any("\x00" in value for value in args):
27
- return Diagnostic(code="invalid_args", message="args cannot contain NUL bytes")
27
+ return Diagnostic(code=ErrorCode.INVALID_ARGS, message="args cannot contain NUL bytes")
28
28
  if "--" in args:
29
- return Diagnostic(code="invalid_args", message="args cannot contain the reserved '--' token")
29
+ return Diagnostic(code=ErrorCode.INVALID_ARGS, message="args cannot contain the reserved '--' token")
30
+ return None
31
+
32
+
33
+ def validate_request(req: Request) -> Diagnostic | None:
34
+ """Validate the request fields that are common to every CLI driver."""
35
+ error = validate_args(req.args)
36
+ if error is not None:
37
+ return error
38
+ if not req.cwd.is_dir():
39
+ return Diagnostic(code=ErrorCode.BAD_CWD, message="cwd is not an existing directory")
30
40
  return None
31
41
 
32
42
 
@@ -36,7 +46,8 @@ def reject_transport_flags(args: Sequence[str], owned: Iterable[str]) -> Diagnos
36
46
  if token is None:
37
47
  return None
38
48
  return Diagnostic(
39
- code="invalid_args", message=f"args cannot override the driver-owned flag {token.split('=', 1)[0]!r}"
49
+ code=ErrorCode.INVALID_ARGS,
50
+ message=f"args cannot override the driver-owned flag {token.split('=', 1)[0]!r}",
40
51
  )
41
52
 
42
53
 
@@ -45,7 +56,9 @@ def reject_unsupported(req: Request, supported: Iterable[str]) -> Diagnostic | N
45
56
  allowed = set(supported)
46
57
  for name in PORTABLE_FIELDS:
47
58
  if getattr(req, name) is not None and name not in allowed:
48
- return Diagnostic(code="unsupported_option", message=f"driver {req.driver!r} does not support {name}")
59
+ return Diagnostic(
60
+ code=ErrorCode.UNSUPPORTED_OPTION, message=f"driver {req.driver!r} does not support {name}"
61
+ )
49
62
  return None
50
63
 
51
64
 
@@ -57,10 +70,17 @@ def reject_conflicts(req: Request, flags: Mapping[str, Iterable[str]]) -> Diagno
57
70
  token = _has_flag(req.args, field_flags)
58
71
  if token is not None:
59
72
  return Diagnostic(
60
- code="invalid_args",
73
+ code=ErrorCode.INVALID_ARGS,
61
74
  message=f"args flag {token.split('=', 1)[0]!r} conflicts with the {name} option",
62
75
  )
63
76
  return None
64
77
 
65
78
 
66
- __all__ = ["PORTABLE_FIELDS", "reject_conflicts", "reject_transport_flags", "reject_unsupported", "validate_args"]
79
+ __all__ = [
80
+ "PORTABLE_FIELDS",
81
+ "reject_conflicts",
82
+ "reject_transport_flags",
83
+ "reject_unsupported",
84
+ "validate_args",
85
+ "validate_request",
86
+ ]
@@ -1,4 +1,4 @@
1
- """Map facts a driver collected about one run to an ``Outcome`` and ``Diagnostic``.
1
+ """Map transport ``RunFacts`` and driver ``Facts`` to a classified ``Result``.
2
2
 
3
3
  Rows follow the design's outcome precedence; the first matching row wins.
4
4
  """
@@ -7,15 +7,22 @@ from __future__ import annotations
7
7
 
8
8
  import re
9
9
  from dataclasses import dataclass
10
+ from typing import Literal
10
11
 
11
- from .types import Diagnostic, Outcome
12
+ from .driver import Facts
13
+ from .types import Diagnostic, ErrorCode, Outcome, Result
12
14
 
13
- REQUEST_CODES = frozenset({"bad_cwd", "invalid_args", "unsupported_option", "invalid_prompt"})
14
- SPAWN_CODES = frozenset({"missing_cli", "spawn_failed"})
15
- CLI_CODES = ("auth", "invalid_model", "context_overflow", "service_error")
15
+ StopCause = Literal["exited", "cancelled", "timeout", "idle_timeout"]
16
16
 
17
- _KEYWORDS: dict[str, tuple[str, ...]] = {
18
- "auth": (
17
+ _CLI_KEYWORD_ORDER: tuple[ErrorCode, ...] = (
18
+ ErrorCode.AUTH,
19
+ ErrorCode.INVALID_MODEL,
20
+ ErrorCode.CONTEXT_OVERFLOW,
21
+ ErrorCode.SERVICE_ERROR,
22
+ )
23
+
24
+ _KEYWORDS: dict[ErrorCode, tuple[str, ...]] = {
25
+ ErrorCode.AUTH: (
19
26
  "authentication",
20
27
  "unauthorized",
21
28
  "forbidden",
@@ -25,9 +32,9 @@ _KEYWORDS: dict[str, tuple[str, ...]] = {
25
32
  "login required",
26
33
  "auth_unavailable",
27
34
  ),
28
- "invalid_model": ("invalid model", "unknown model", "not a valid model"),
29
- "context_overflow": ("context_length_exceeded",),
30
- "service_error": (
35
+ ErrorCode.INVALID_MODEL: ("invalid model", "unknown model", "not a valid model"),
36
+ ErrorCode.CONTEXT_OVERFLOW: ("context_length_exceeded",),
37
+ ErrorCode.SERVICE_ERROR: (
31
38
  "overloaded",
32
39
  "rate limit",
33
40
  "rate_limit",
@@ -38,133 +45,217 @@ _KEYWORDS: dict[str, tuple[str, ...]] = {
38
45
  ),
39
46
  }
40
47
  _CONTEXT_OVERFLOW_RE = re.compile(r"\bcontext_length_exceeded\b")
48
+ _MODEL_NOT_FOUND_RE = re.compile(r'\bmodel\s+"[^"]+"\s+not\s+found\b')
49
+ _HTTP_STATUS_RE = re.compile(
50
+ r"""\b(?:http(?:/\d(?:\.\d)?)?(?:\s+error)?|status(?:\s+code)?)\s*[:=]?\s*"?([1-5]\d{2})\b"""
51
+ r"""|"(?:status|status_code|statusCode)"\s*:\s*(\d{3})\b""",
52
+ re.IGNORECASE,
53
+ )
41
54
 
42
55
 
43
56
  def _normalize(text: str) -> str:
44
57
  return text.lower().replace("node_tls_reject_unauthorized", "")
45
58
 
46
59
 
47
- def keyword_code(text: str) -> str:
48
- """Return the first CLI code whose keywords appear in ``text``, in precedence order, or ""."""
60
+ def _http_codes_from_text(text: str) -> set[ErrorCode]:
61
+ codes = (http_status_code(int(m.group(1) or m.group(2))) for m in _HTTP_STATUS_RE.finditer(text))
62
+ return {code for code in codes if code is not None}
63
+
64
+
65
+ def keyword_code(text: str) -> ErrorCode | None:
66
+ """Return the first CLI ``ErrorCode`` whose keywords appear in ``text``, in precedence order, or ``None``."""
49
67
  lowered = _normalize(text)
50
68
  if not lowered.strip():
51
- return ""
52
- for code in CLI_CODES:
53
- if code == "context_overflow":
69
+ return None
70
+ http_codes = _http_codes_from_text(lowered)
71
+ for code in _CLI_KEYWORD_ORDER:
72
+ if code == ErrorCode.CONTEXT_OVERFLOW:
54
73
  if _CONTEXT_OVERFLOW_RE.search(lowered):
55
74
  return code
56
- elif any(token in lowered for token in _KEYWORDS[code]):
75
+ elif code == ErrorCode.INVALID_MODEL and _MODEL_NOT_FOUND_RE.search(lowered):
76
+ return code
77
+ elif any(token in lowered for token in _KEYWORDS[code]) or code in http_codes:
57
78
  return code
58
- return ""
79
+ return None
59
80
 
60
81
 
61
- def http_status_code(status: int | None) -> str:
62
- """Map a CLI-reported HTTP status to a CLI code, or ""."""
82
+ def http_status_code(status: int | None) -> ErrorCode | None:
83
+ """Map a CLI-reported HTTP status to an ``ErrorCode``, or ``None``."""
63
84
  if status in (401, 403):
64
- return "auth"
85
+ return ErrorCode.AUTH
65
86
  if status == 429 or (status is not None and 500 <= status <= 599):
66
- return "service_error"
67
- return ""
87
+ return ErrorCode.SERVICE_ERROR
88
+ return None
68
89
 
69
90
 
70
- @dataclass(slots=True, kw_only=True)
71
- class Facts:
72
- """What a driver observed. ``cli_error`` is structured CLI error data; ``diagnostics`` is free text."""
91
+ @dataclass(frozen=True, slots=True, kw_only=True)
92
+ class RunFacts:
93
+ """Transport and supervision facts collected for one run."""
73
94
 
74
- text: str = ""
75
- session_id: str | None = None
76
- cancelled: bool = False
77
- request_error: Diagnostic | None = None
78
- process_error: str = ""
79
- process_message: str = ""
80
- cli_error: Diagnostic | None = None
81
- diagnostics: str = ""
95
+ stop_cause: StopCause = "exited"
82
96
  returncode: int | None = None
97
+ stderr_tail: str = ""
98
+ request_error: Diagnostic | None = None
99
+ spawn_error: Diagnostic | None = None
100
+ stdin_failed: str | None = None
101
+ parser_error: str | None = None
102
+ cleanup_incomplete: bool = False
103
+ line_too_long: bool = False
104
+ duration_s: float = 0.0
83
105
 
84
106
 
85
- @dataclass(frozen=True, slots=True)
86
- class Classification:
87
- outcome: Outcome
88
- error: Diagnostic | None
89
- warnings: tuple[Diagnostic, ...] = ()
107
+ def _message(*parts: str) -> str:
108
+ return "\n".join(part.strip() for part in parts if part and part.strip())
90
109
 
91
110
 
92
- _CLI_OUTCOMES = {
93
- "auth": Outcome.AGENT_FATAL,
94
- "invalid_model": Outcome.REQUEST_FATAL,
95
- "context_overflow": Outcome.REQUEST_FATAL,
96
- "service_error": Outcome.RETRYABLE,
97
- }
111
+ def _cli_diagnostic(run: RunFacts, facts: Facts) -> Diagnostic | None:
112
+ """Return structured ``Facts.error`` if set, else keyword-match ``Facts.error_text`` and ``stderr_tail``."""
113
+ if facts.error is not None:
114
+ return facts.error
115
+ combined = _message(facts.error_text, run.stderr_tail)
116
+ code = keyword_code(combined)
117
+ if code is not None:
118
+ return Diagnostic(code=code, message=combined)
119
+ return None
98
120
 
99
121
 
100
- def _cli_diagnostic(facts: Facts) -> Diagnostic | None:
101
- """Structured CLI error data first, then keyword matching on diagnostics."""
102
- if facts.cli_error is not None and facts.cli_error.code in _CLI_OUTCOMES:
103
- return facts.cli_error
104
- code = keyword_code(facts.diagnostics)
105
- if code:
106
- return Diagnostic(code=code, message=facts.diagnostics.strip())
107
- if facts.cli_error is not None:
108
- code = keyword_code(facts.cli_error.message)
109
- if code:
110
- return Diagnostic(code=code, message=facts.cli_error.message)
111
- return None
122
+ def _build_warnings(run: RunFacts, extra: tuple[Diagnostic, ...] = ()) -> tuple[Diagnostic, ...]:
123
+ warnings: list[Diagnostic] = []
124
+ if run.line_too_long:
125
+ warnings.append(
126
+ Diagnostic(code=ErrorCode.LINE_TOO_LONG, message="a line exceeded max_line_bytes and was discarded")
127
+ )
128
+ if run.cleanup_incomplete:
129
+ warnings.append(
130
+ Diagnostic(
131
+ code=ErrorCode.CLEANUP_INCOMPLETE,
132
+ message="process cleanup did not complete before deadline",
133
+ )
134
+ )
135
+ warnings.extend(extra)
136
+ return tuple(warnings)
112
137
 
113
138
 
114
- def _message(*parts: str) -> str:
115
- return "\n".join(part.strip() for part in parts if part and part.strip())
139
+ def _result(
140
+ run: RunFacts,
141
+ facts: Facts,
142
+ *,
143
+ text: str,
144
+ outcome: Outcome,
145
+ error: Diagnostic | None,
146
+ extra_warnings: tuple[Diagnostic, ...] = (),
147
+ ) -> Result:
148
+ return Result(
149
+ outcome=outcome,
150
+ text=text,
151
+ session_id=facts.session_id or None,
152
+ model=facts.model,
153
+ usage=facts.usage,
154
+ cost_usd=facts.cost_usd,
155
+ duration_s=run.duration_s,
156
+ num_turns=facts.num_turns,
157
+ stop_reason=facts.stop_reason,
158
+ returncode=run.returncode,
159
+ error=error,
160
+ warnings=_build_warnings(run, extra_warnings),
161
+ )
116
162
 
117
163
 
118
- def classify(facts: Facts) -> Classification:
119
- # Row 1
120
- if facts.cancelled or facts.process_error == "cancelled":
121
- return Classification(Outcome.CANCELLED, Diagnostic(code="cancelled", message="run cancelled"))
122
- # Rows 2 and 3
123
- if facts.request_error is not None:
124
- return Classification(Outcome.REQUEST_FATAL, facts.request_error)
125
- if facts.process_error in REQUEST_CODES:
126
- return Classification(
127
- Outcome.REQUEST_FATAL, Diagnostic(code=facts.process_error, message=facts.process_message)
164
+ def classify(run: RunFacts, facts: Facts, *, text: str) -> Result:
165
+ """Classify one run into a ``Result`` according to the design's 12-row precedence table."""
166
+ result_text = facts.final_text if facts.final_text is not None else text
167
+
168
+ # Row 1: Latched stop cause is cancelled
169
+ if run.stop_cause == "cancelled":
170
+ return _result(
171
+ run,
172
+ facts,
173
+ text=result_text,
174
+ outcome=Outcome.CANCELLED,
175
+ error=Diagnostic(code=ErrorCode.CANCELLED, message="run cancelled"),
128
176
  )
129
- # Row 4
130
- if facts.process_error in SPAWN_CODES:
131
- return Classification(Outcome.AGENT_FATAL, Diagnostic(code=facts.process_error, message=facts.process_message))
132
- # Rows 5 to 7, and 9 after the timeout row
133
- cli = _cli_diagnostic(facts)
134
- if cli is not None and cli.code != "service_error":
135
- return Classification(_CLI_OUTCOMES[cli.code], cli)
136
- # Row 8
137
- if facts.process_error == "timeout":
138
- return Classification(
139
- Outcome.RETRYABLE, Diagnostic(code="timeout", message=facts.process_message or "run timed out")
177
+ # Row 2: Validation failure or RequestError
178
+ if run.request_error is not None:
179
+ return _result(run, facts, text=result_text, outcome=Outcome.REQUEST_FATAL, error=run.request_error)
180
+ # Row 3: Executable missing or spawn denied
181
+ if run.spawn_error is not None:
182
+ return _result(run, facts, text=result_text, outcome=Outcome.AGENT_FATAL, error=run.spawn_error)
183
+
184
+ cli = _cli_diagnostic(run, facts)
185
+
186
+ # Row 4: Auth failure
187
+ if cli is not None and cli.code == ErrorCode.AUTH:
188
+ return _result(run, facts, text=result_text, outcome=Outcome.AGENT_FATAL, error=cli)
189
+ # Row 5: Invalid model or context overflow
190
+ if cli is not None and cli.code in (ErrorCode.INVALID_MODEL, ErrorCode.CONTEXT_OVERFLOW):
191
+ return _result(run, facts, text=result_text, outcome=Outcome.REQUEST_FATAL, error=cli)
192
+ # Row 6: Parser raised
193
+ if run.parser_error is not None:
194
+ return _result(
195
+ run,
196
+ facts,
197
+ text=result_text,
198
+ outcome=Outcome.AGENT_FATAL,
199
+ error=Diagnostic(code=ErrorCode.PARSER_ERROR, message=run.parser_error),
140
200
  )
141
- if cli is not None:
142
- return Classification(Outcome.RETRYABLE, cli)
143
- # Row 10
144
- if facts.process_error in {"execution_error", "stdin_failed"}:
145
- message = _message(facts.process_message, facts.diagnostics if not facts.text.strip() else "")
146
- return Classification(Outcome.RETRYABLE, Diagnostic(code=facts.process_error, message=message))
147
- if facts.cli_error is not None:
148
- return Classification(Outcome.RETRYABLE, Diagnostic(code="execution_error", message=facts.cli_error.message))
149
- if facts.returncode:
150
- return Classification(
151
- Outcome.RETRYABLE,
152
- Diagnostic(
153
- code="execution_error",
154
- message=_message(f"command exited with status {facts.returncode}", facts.diagnostics),
201
+ # Row 7: CLI reports a caller-set limit
202
+ if cli is not None and cli.code in (ErrorCode.MAX_TURNS, ErrorCode.BUDGET):
203
+ return _result(run, facts, text=result_text, outcome=Outcome.LIMIT_REACHED, error=cli)
204
+ # Row 8: Latched stop cause is a deadline
205
+ if run.stop_cause in ("timeout", "idle_timeout"):
206
+ code = ErrorCode.TIMEOUT if run.stop_cause == "timeout" else ErrorCode.IDLE_TIMEOUT
207
+ msg = "run timed out" if code == ErrorCode.TIMEOUT else "run idle-timed out"
208
+ return _result(
209
+ run,
210
+ facts,
211
+ text=result_text,
212
+ outcome=Outcome.RETRYABLE,
213
+ error=Diagnostic(code=code, message=msg),
214
+ )
215
+ # Row 9: HTTP 5xx, overload, or rate limit
216
+ if cli is not None and cli.code == ErrorCode.SERVICE_ERROR:
217
+ return _result(run, facts, text=result_text, outcome=Outcome.RETRYABLE, error=cli)
218
+ # Row 10: Stdin failure, nonzero exit, or structured failure
219
+ if run.stdin_failed is not None:
220
+ return _result(
221
+ run,
222
+ facts,
223
+ text=result_text,
224
+ outcome=Outcome.RETRYABLE,
225
+ error=Diagnostic(
226
+ code=ErrorCode.STDIN_FAILED, message=run.stdin_failed or "failed to write prompt to stdin"
227
+ ),
228
+ )
229
+ if facts.error is not None:
230
+ return _result(run, facts, text=result_text, outcome=Outcome.RETRYABLE, error=facts.error)
231
+ if run.returncode:
232
+ return _result(
233
+ run,
234
+ facts,
235
+ text=result_text,
236
+ outcome=Outcome.RETRYABLE,
237
+ error=Diagnostic(
238
+ code=ErrorCode.EXECUTION_ERROR,
239
+ message=_message(f"command exited with status {run.returncode}", facts.error_text, run.stderr_tail),
155
240
  ),
156
241
  )
157
- if not facts.text.strip():
158
- return Classification(
159
- Outcome.RETRYABLE,
160
- Diagnostic(code="no_output", message=_message("the CLI produced no assistant text", facts.diagnostics)),
242
+ # Row 11: No text
243
+ if not result_text.strip():
244
+ return _result(
245
+ run,
246
+ facts,
247
+ text=result_text,
248
+ outcome=Outcome.RETRYABLE,
249
+ error=Diagnostic(
250
+ code=ErrorCode.NO_OUTPUT,
251
+ message=_message("the CLI produced no assistant text", facts.error_text, run.stderr_tail),
252
+ ),
161
253
  )
162
- # Row 11
254
+ # Row 12: Otherwise
255
+ extra: tuple[Diagnostic, ...] = ()
163
256
  if not facts.session_id:
164
- return Classification(
165
- Outcome.SUCCESS, None, (Diagnostic(code="no_session_id", message="the CLI reported no session ID"),)
166
- )
167
- return Classification(Outcome.SUCCESS, None)
257
+ extra = (Diagnostic(code=ErrorCode.NO_SESSION_ID, message="the CLI reported no session ID"),)
258
+ return _result(run, facts, text=result_text, outcome=Outcome.SUCCESS, error=None, extra_warnings=extra)
168
259
 
169
260
 
170
- __all__ = ["Classification", "Facts", "classify", "http_status_code", "keyword_code"]
261
+ __all__ = ["RunFacts", "StopCause", "classify", "http_status_code", "keyword_code"]