codeer-cli 0.1.8__py3-none-any.whl → 0.1.10__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.
codeer_cli/__init__.py CHANGED
@@ -18,8 +18,8 @@ production and can be overridden from process env. The CLI does not read
18
18
  workspace-local dotenv files or credential files.
19
19
  """
20
20
 
21
- from ._validate import ToolValidationError
22
- from .client import AuthError, CodeerClient, CodeerError
21
+ from ._validate import HumanHandoffValidationError, ToolValidationError
22
+ from .client import AuthError, CodeerClient, CodeerError, TransportError
23
23
  from .parse import (
24
24
  AgentSummary,
25
25
  ConversationTurn,
@@ -43,7 +43,8 @@ from .parse import (
43
43
  )
44
44
 
45
45
  __all__ = [
46
- "CodeerClient", "CodeerError", "AuthError", "ToolValidationError",
46
+ "CodeerClient", "CodeerError", "TransportError", "AuthError", "ToolValidationError",
47
+ "HumanHandoffValidationError",
47
48
  # parsers
48
49
  "AgentSummary", "ConversationTurn", "EvalResultSummary", "HistorySummary",
49
50
  "KBNode", "ToolCall", "EvalToolCall",
codeer_cli/_validate.py CHANGED
@@ -1,4 +1,4 @@
1
- """Client-side validation for unified-tool payloads.
1
+ """Client-side validation for agent payloads.
2
2
 
3
3
  These checks exist because the backend's form-schema validator is lenient
4
4
  (``extra="allow"``) and silently accepts unknown ``type`` strings, which then
@@ -24,6 +24,47 @@ class ToolValidationError(ValueError):
24
24
  """Raised when a unified_tools payload is definitely wrong."""
25
25
 
26
26
 
27
+ class HumanHandoffValidationError(ValueError):
28
+ """Raised when a human_handoff payload is definitely wrong."""
29
+
30
+
31
+ def validate_human_handoff(config: Any) -> dict[str, Any] | None:
32
+ """Validate and normalize an optional human-handoff configuration."""
33
+ if config is None:
34
+ return None
35
+ if not isinstance(config, dict):
36
+ raise HumanHandoffValidationError("human_handoff must be an object.")
37
+
38
+ allowed_keys = {"enabled", "idle_timeout_minutes", "handoff_instructions"}
39
+ unknown_keys = sorted(set(config) - allowed_keys)
40
+ if unknown_keys:
41
+ raise HumanHandoffValidationError(
42
+ f"human_handoff contains unsupported field(s): {', '.join(unknown_keys)}."
43
+ )
44
+
45
+ enabled = config.get("enabled", False)
46
+ if not isinstance(enabled, bool):
47
+ raise HumanHandoffValidationError("human_handoff.enabled must be true or false.")
48
+
49
+ timeout = config.get("idle_timeout_minutes")
50
+ if timeout is not None and (type(timeout) is not int or timeout <= 0):
51
+ raise HumanHandoffValidationError(
52
+ "human_handoff.idle_timeout_minutes must be a positive integer or null."
53
+ )
54
+
55
+ instructions = config.get("handoff_instructions")
56
+ if instructions is not None and not isinstance(instructions, str):
57
+ raise HumanHandoffValidationError(
58
+ "human_handoff.handoff_instructions must be a string or null."
59
+ )
60
+
61
+ return {
62
+ "enabled": enabled,
63
+ "idle_timeout_minutes": timeout,
64
+ "handoff_instructions": instructions,
65
+ }
66
+
67
+
27
68
  def validate_unified_tools(tools: Iterable[dict[str, Any]] | None) -> list[dict[str, Any]]:
28
69
  """Raise ToolValidationError on invalid payloads; otherwise return the list.
29
70
 
codeer_cli/agents.py CHANGED
@@ -7,7 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  from typing import Any, List, Optional
9
9
 
10
- from ._validate import validate_unified_tools
10
+ from ._validate import validate_human_handoff, validate_unified_tools
11
11
  from .client import CodeerClient
12
12
 
13
13
 
@@ -24,8 +24,10 @@ def create(
24
24
  suggested_questions: Optional[List[str]] = None,
25
25
  primary_object_ids: Optional[List[int]] = None,
26
26
  attachment_ids: Optional[List[str]] = None,
27
+ human_handoff: Optional[dict[str, Any]] = None,
27
28
  ) -> dict:
28
29
  validated_tools = validate_unified_tools(unified_tools)
30
+ validated_handoff = validate_human_handoff(human_handoff)
29
31
  body: dict[str, Any] = {
30
32
  "name": name,
31
33
  "system_prompt": system_prompt,
@@ -39,6 +41,8 @@ def create(
39
41
  body["description"] = description
40
42
  if llm_model is not None:
41
43
  body["llm_model"] = llm_model
44
+ if validated_handoff is not None:
45
+ body["human_handoff"] = validated_handoff
42
46
  return client.post("/external/agents", json=body)
43
47
 
44
48
 
@@ -56,9 +60,11 @@ def update(
56
60
  suggested_questions: Optional[List[str]] = None,
57
61
  primary_object_ids: Optional[List[int]] = None,
58
62
  attachment_ids: Optional[List[str]] = None,
63
+ human_handoff: Optional[dict[str, Any]] = None,
59
64
  ) -> dict:
60
65
  """PUT creates a new AgentHistory snapshot (draft)."""
61
66
  validated_tools = validate_unified_tools(unified_tools)
67
+ validated_handoff = validate_human_handoff(human_handoff)
62
68
  body: dict[str, Any] = {
63
69
  "name": name,
64
70
  "system_prompt": system_prompt,
@@ -73,6 +79,8 @@ def update(
73
79
  body["description"] = description
74
80
  if llm_model is not None:
75
81
  body["llm_model"] = llm_model
82
+ if validated_handoff is not None:
83
+ body["human_handoff"] = validated_handoff
76
84
  return client.patch(f"/external/agents/{agent_id}", json=body)
77
85
 
78
86
 
codeer_cli/chats.py CHANGED
@@ -36,6 +36,7 @@ def send_published_agent_message(
36
36
  external_user_id: Optional[str] = None,
37
37
  attachment_ids: Optional[List[str]] = None,
38
38
  stream: bool = False,
39
+ timeout: Optional[float] = None,
39
40
  ) -> Iterator[dict] | dict:
40
41
  """Send a user message through the API-key external chat flow.
41
42
 
@@ -51,7 +52,7 @@ def send_published_agent_message(
51
52
  path = f"/chats/{chat_id}/messages"
52
53
  if stream:
53
54
  return client.stream_sse("POST", path, json=body)
54
- return client.post(path, json=body)
55
+ return client.post(path, json=body, timeout=timeout)
55
56
 
56
57
 
57
58
  def send_message(
@@ -84,4 +85,3 @@ def list_messages(client: CodeerClient, chat_id: int) -> list[dict]:
84
85
 
85
86
  def list_chats(client: CodeerClient) -> list[dict]:
86
87
  return client.get("/chats")
87
-
codeer_cli/cli.py CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  codeer check
4
4
  codeer agent list|get|apply|diff|versions
5
+ codeer model list
5
6
  codeer kb list|files|upload|node-rename|node-delete|faq-list|faq-get|faq-create|faq-update|faq-delete
6
7
  codeer eval list|label-list|label-create|label-update|label-delete|case-update|case-delete|evaluators|evaluator-create|evaluator-update|run|export|reconcile|cases-apply|rubrics|rubrics-apply
7
- codeer history list|get|conversations|negative-feedback
8
+ codeer history list|get|conversations|negative-feedback|create|send
8
9
  """
9
10
 
10
11
  from __future__ import annotations
@@ -25,6 +26,7 @@ def main(argv: list[str] | None = None) -> int:
25
26
  epilog="""\
26
27
  Safe workflow for coding agents:
27
28
  codeer check --json
29
+ codeer model list --type text
28
30
  codeer agent list
29
31
  codeer agent get <agent-id> --full
30
32
  codeer kb list
@@ -55,6 +57,12 @@ Use --out <path> for large raw artifacts; stdout defaults to compact summaries.
55
57
 
56
58
  check.register(sub)
57
59
 
60
+ try:
61
+ from .commands import model as model_cmd
62
+ model_cmd.register(sub)
63
+ except ImportError:
64
+ pass
65
+
58
66
  # Phase 2-4: agent, kb, eval commands will register here
59
67
  try:
60
68
  from .commands import agent as agent_cmd
codeer_cli/client.py CHANGED
@@ -37,6 +37,16 @@ class ScopeResolutionError(CodeerError):
37
37
  """Raised when workspace or organization scope cannot be inferred."""
38
38
 
39
39
 
40
+ class TransportError(CodeerError):
41
+ """Raised when an HTTP request fails before a response is available."""
42
+
43
+ def __init__(self, message: str, body: Any = None):
44
+ RuntimeError.__init__(self, message)
45
+ self.status = 0
46
+ self.message = message
47
+ self.body = body
48
+
49
+
40
50
  @dataclass
41
51
  class CodeerClient:
42
52
  """Thin wrapper around httpx.Client with Codeer API-key auth.
@@ -148,9 +158,32 @@ class CodeerClient:
148
158
  json: Any = None,
149
159
  files: Any = None,
150
160
  data: Any = None,
161
+ timeout: Optional[float] = None,
151
162
  ) -> Any:
152
163
  url = path if path.startswith("http") else f"/api/v1{path if path.startswith('/') else '/' + path}"
153
- r = self._client.request(method, url, params=params, json=json, files=files, data=data)
164
+ request_kwargs: dict[str, Any] = {}
165
+ if timeout is not None:
166
+ request_kwargs["timeout"] = timeout
167
+ method_upper = method.upper()
168
+ try:
169
+ r = self._client.request(
170
+ method_upper,
171
+ url,
172
+ params=params,
173
+ json=json,
174
+ files=files,
175
+ data=data,
176
+ **request_kwargs,
177
+ )
178
+ except httpx.TimeoutException as exc:
179
+ raise self._transport_error(
180
+ method_upper,
181
+ path,
182
+ exc,
183
+ timeout_seconds=timeout if timeout is not None else self.timeout,
184
+ ) from exc
185
+ except httpx.RequestError as exc:
186
+ raise self._transport_error(method_upper, path, exc) from exc
154
187
  return self._parse(r)
155
188
 
156
189
  def get(self, path: str, **kwargs: Any) -> Any:
@@ -181,29 +214,68 @@ class CodeerClient:
181
214
  Each event is a dict like ``{"event": "message", "data": <parsed-json-or-str>}``.
182
215
  """
183
216
  url = path if path.startswith("http") else f"/api/v1{path if path.startswith('/') else '/' + path}"
184
- with self._client.stream(method, url, params=params, json=json) as r:
185
- if r.status_code >= 400:
186
- body = r.read().decode("utf-8", "replace")
187
- self._raise_for_error(r.status_code, body)
188
- event = "message"
189
- buf: list[str] = []
190
- for line in r.iter_lines():
191
- if line == "":
192
- if buf:
193
- raw = "\n".join(buf)
194
- yield {"event": event, "data": _maybe_json(raw)}
195
- buf = []
196
- event = "message"
197
- continue
198
- if line.startswith(":"):
199
- continue
200
- if line.startswith("event:"):
201
- event = line[len("event:"):].strip()
202
- continue
203
- if line.startswith("data:"):
204
- buf.append(line[len("data:"):].lstrip())
205
- if buf:
206
- yield {"event": event, "data": _maybe_json("\n".join(buf))}
217
+ method_upper = method.upper()
218
+ try:
219
+ with self._client.stream(method_upper, url, params=params, json=json) as r:
220
+ if r.status_code >= 400:
221
+ body = r.read().decode("utf-8", "replace")
222
+ self._raise_for_error(r.status_code, body)
223
+ event = "message"
224
+ buf: list[str] = []
225
+ for line in r.iter_lines():
226
+ if line == "":
227
+ if buf:
228
+ raw = "\n".join(buf)
229
+ yield {"event": event, "data": _maybe_json(raw)}
230
+ buf = []
231
+ event = "message"
232
+ continue
233
+ if line.startswith(":"):
234
+ continue
235
+ if line.startswith("event:"):
236
+ event = line[len("event:"):].strip()
237
+ continue
238
+ if line.startswith("data:"):
239
+ buf.append(line[len("data:"):].lstrip())
240
+ if buf:
241
+ yield {"event": event, "data": _maybe_json("\n".join(buf))}
242
+ except httpx.TimeoutException as exc:
243
+ raise self._transport_error(
244
+ method_upper,
245
+ path,
246
+ exc,
247
+ timeout_seconds=self.timeout,
248
+ ) from exc
249
+ except httpx.RequestError as exc:
250
+ raise self._transport_error(method_upper, path, exc) from exc
251
+
252
+ def _transport_error(
253
+ self,
254
+ method: str,
255
+ path: str,
256
+ exc: httpx.RequestError,
257
+ *,
258
+ timeout_seconds: float | None = None,
259
+ ) -> TransportError:
260
+ outcome_uncertain = (
261
+ method not in {"GET", "HEAD", "OPTIONS"}
262
+ and not isinstance(exc, httpx.ConnectError)
263
+ )
264
+ if isinstance(exc, httpx.TimeoutException):
265
+ timeout_value = timeout_seconds if timeout_seconds is not None else self.timeout
266
+ message = f"Request timed out after {timeout_value:g}s: {method} {path}."
267
+ if outcome_uncertain:
268
+ message += " The server may have completed the request; inspect current state before retrying."
269
+ else:
270
+ message = f"Request failed: {method} {path}: {exc}"
271
+ return TransportError(
272
+ message,
273
+ {
274
+ "method": method,
275
+ "path": path,
276
+ "outcome_uncertain": outcome_uncertain,
277
+ },
278
+ )
207
279
 
208
280
  def _parse(self, r: httpx.Response) -> Any:
209
281
  text = r.text
@@ -6,6 +6,7 @@ from pathlib import Path
6
6
  from typing import Optional
7
7
 
8
8
  from .. import agents as agents_mod
9
+ from .._validate import validate_human_handoff, validate_unified_tools
9
10
  from ..client import CodeerClient
10
11
  from ._util import log, print_json, strip_noisy_fields, truncate, write_json
11
12
 
@@ -102,6 +103,12 @@ def _tool_summary(tools: list[dict] | None) -> list[dict]:
102
103
 
103
104
  def _agent_summary(agent: dict, *, full: bool = False) -> dict:
104
105
  tools = agent.get("unified_tools") or agent.get("tools") or []
106
+ human_handoff = agent.get("human_handoff")
107
+ if not isinstance(human_handoff, dict):
108
+ meta = agent.get("meta")
109
+ human_handoff = meta.get("human_handoff") if isinstance(meta, dict) else {}
110
+ if not isinstance(human_handoff, dict):
111
+ human_handoff = {}
105
112
  row = {
106
113
  "id": agent.get("id"),
107
114
  "name": agent.get("name"),
@@ -118,6 +125,7 @@ def _agent_summary(agent: dict, *, full: bool = False) -> dict:
118
125
  "agent_type": agent.get("agent_type"),
119
126
  "updated_at": agent.get("updated_at"),
120
127
  "tool_count": len(tools),
128
+ "human_handoff_enabled": bool(human_handoff.get("enabled")),
121
129
  "system_prompt_chars": len(agent.get("system_prompt") or ""),
122
130
  }
123
131
  if full:
@@ -125,6 +133,7 @@ def _agent_summary(agent: dict, *, full: bool = False) -> dict:
125
133
  row["use_search"] = agent.get("use_search")
126
134
  row["suggested_questions"] = agent.get("suggested_questions") or []
127
135
  row["tools"] = _tool_summary(tools)
136
+ row["human_handoff"] = human_handoff
128
137
  row["system_prompt_preview"] = truncate(agent.get("system_prompt") or "", 1200)
129
138
  return row
130
139
 
@@ -153,6 +162,13 @@ def run_apply(args, client) -> int:
153
162
  log(f"error: payload missing required field(s): {', '.join(missing)}")
154
163
  return 2
155
164
 
165
+ try:
166
+ validated_tools = validate_unified_tools(body.get("unified_tools") or [])
167
+ validated_handoff = validate_human_handoff(body.get("human_handoff"))
168
+ except ValueError as exc:
169
+ log(f"error: invalid agent payload: {exc}")
170
+ return 2
171
+
156
172
  if args.dry_run:
157
173
  operation = "update" if args.agent_id else "create"
158
174
  result = {
@@ -162,9 +178,14 @@ def run_apply(args, client) -> int:
162
178
  "payload": str(Path(args.payload)),
163
179
  "name": body.get("name"),
164
180
  "system_prompt_chars": len(body.get("system_prompt") or ""),
165
- "tool_count": len(body.get("unified_tools") or []),
181
+ "tool_count": len(validated_tools),
166
182
  "use_search": body.get("use_search", False),
167
183
  "llm_model": body.get("llm_model"),
184
+ "human_handoff": {
185
+ "enabled": bool((validated_handoff or {}).get("enabled")),
186
+ "idle_timeout_minutes": (validated_handoff or {}).get("idle_timeout_minutes"),
187
+ "instructions_chars": len((validated_handoff or {}).get("handoff_instructions") or ""),
188
+ },
168
189
  "version_note": args.note if args.agent_id else None,
169
190
  "would_write_server_state": True,
170
191
  "next_step": "Review this summary, then rerun without --dry-run after approval.",
@@ -178,7 +199,7 @@ def run_apply(args, client) -> int:
178
199
  client, args.agent_id,
179
200
  name=body["name"],
180
201
  system_prompt=body["system_prompt"],
181
- unified_tools=body.get("unified_tools") or [],
202
+ unified_tools=validated_tools,
182
203
  use_search=body.get("use_search", False),
183
204
  version_note=args.note,
184
205
  description=body.get("description"),
@@ -186,6 +207,7 @@ def run_apply(args, client) -> int:
186
207
  suggested_questions=body.get("suggested_questions") or [],
187
208
  primary_object_ids=body.get("primary_object_ids") or [],
188
209
  attachment_ids=body.get("attachment_ids") or [],
210
+ human_handoff=validated_handoff,
189
211
  )
190
212
  agent_id = args.agent_id
191
213
  log(f"PUT /agents/{agent_id} ok")
@@ -197,13 +219,14 @@ def run_apply(args, client) -> int:
197
219
  workspace_id=body["workspace_id"],
198
220
  name=body["name"],
199
221
  system_prompt=body["system_prompt"],
200
- unified_tools=body.get("unified_tools") or [],
222
+ unified_tools=validated_tools,
201
223
  use_search=body.get("use_search", False),
202
224
  description=body.get("description"),
203
225
  llm_model=body.get("llm_model"),
204
226
  suggested_questions=body.get("suggested_questions") or [],
205
227
  primary_object_ids=body.get("primary_object_ids") or [],
206
228
  attachment_ids=body.get("attachment_ids") or [],
229
+ human_handoff=validated_handoff,
207
230
  )
208
231
  agent_id = agent["id"]
209
232
  log(f"POST /agents ok, id={agent_id}")
@@ -48,14 +48,12 @@ def register(subparsers):
48
48
 
49
49
  # codeer eval label-list/create/update/delete
50
50
  p = sub.add_parser("label-list", help="List eval case labels in the workspace")
51
- p.add_argument("--workspace", default=None, help="Workspace UUID (default: active API-key workspace)")
52
51
  p.add_argument("--out", default=None)
53
52
  p.set_defaults(func=run_label_list)
54
53
 
55
54
  p = sub.add_parser("label-create", help="Create an eval case label; run --dry-run first")
56
55
  p.add_argument("--name", required=True)
57
56
  p.add_argument("--color", default=None, help="Hex color like #0969da (default: server default)")
58
- p.add_argument("--workspace", default=None, help="Workspace UUID (default: active API-key workspace)")
59
57
  p.add_argument("--dry-run", action="store_true")
60
58
  p.add_argument("--out", default=None)
61
59
  p.set_defaults(func=run_label_create)
@@ -288,9 +286,7 @@ def run_list(args, client) -> int:
288
286
  # eval case labels
289
287
  # ---------------------------------------------------------------------------
290
288
 
291
- def _workspace_arg_or_default(client, workspace_id: str | None) -> str:
292
- if workspace_id:
293
- return workspace_id
289
+ def _active_workspace(client) -> str:
294
290
  ws, _ = client.resolve_scope()
295
291
  return ws
296
292
 
@@ -305,8 +301,8 @@ def _label_summary(label: dict) -> dict:
305
301
 
306
302
 
307
303
  def run_label_list(args, client) -> int:
308
- workspace_id = _workspace_arg_or_default(client, args.workspace)
309
- labels = eval_mod.list_case_labels(client, workspace_id=workspace_id)
304
+ workspace_id = _active_workspace(client)
305
+ labels = eval_mod.list_case_labels(client)
310
306
  out = {
311
307
  "workspace_id": workspace_id,
312
308
  "label_count": len(labels),
@@ -318,13 +314,13 @@ def run_label_list(args, client) -> int:
318
314
 
319
315
 
320
316
  def run_label_create(args, client) -> int:
321
- workspace_id = _workspace_arg_or_default(client, args.workspace)
317
+ workspace_id = _active_workspace(client)
322
318
  if args.dry_run:
323
319
  out = {
324
320
  "dry_run": True,
325
321
  "operation": "label_create",
326
322
  "method": "POST",
327
- "path": f"/eval/workspaces/{workspace_id}/case-labels",
323
+ "path": "/external/eval/case-labels",
328
324
  "workspace_id": workspace_id,
329
325
  "name": args.name,
330
326
  "color": args.color,
@@ -335,9 +331,7 @@ def run_label_create(args, client) -> int:
335
331
  write_json(args.out, out)
336
332
  return 0
337
333
 
338
- label = eval_mod.create_case_label(
339
- client, workspace_id=workspace_id, name=args.name, color=args.color
340
- )
334
+ label = eval_mod.create_case_label(client, name=args.name, color=args.color)
341
335
  out = _label_summary(strip_noisy_fields(label))
342
336
  print_json(out)
343
337
  write_json(args.out, out)
@@ -354,7 +348,7 @@ def run_label_update(args, client) -> int:
354
348
  "dry_run": True,
355
349
  "operation": "label_update",
356
350
  "method": "PUT",
357
- "path": f"/eval/case-labels/{args.label_id}",
351
+ "path": f"/external/eval/case-labels/{args.label_id}",
358
352
  "label_id": args.label_id,
359
353
  "updates": {"name": args.name, "color": args.color},
360
354
  "would_write_server_state": True,
@@ -379,7 +373,7 @@ def run_label_delete(args, client) -> int:
379
373
  "dry_run": True,
380
374
  "operation": "label_delete",
381
375
  "method": "DELETE",
382
- "path": f"/eval/case-labels/{args.label_id}",
376
+ "path": f"/external/eval/case-labels/{args.label_id}",
383
377
  "label_id": args.label_id,
384
378
  "would_write_server_state": True,
385
379
  "next_step": "Review this summary, then rerun without --dry-run after approval.",
@@ -1384,7 +1378,7 @@ def run_cases_apply(args, client) -> int:
1384
1378
  if manifest_label_names:
1385
1379
  labels_by_name = {
1386
1380
  (label.get("name") or "").casefold(): label
1387
- for label in eval_mod.list_case_labels(client, workspace_id=workspace_id)
1381
+ for label in eval_mod.list_case_labels(client)
1388
1382
  if label.get("name")
1389
1383
  }
1390
1384
  missing_label_names = [
@@ -1410,7 +1404,7 @@ def run_cases_apply(args, client) -> int:
1410
1404
  else:
1411
1405
  for name in missing_label_names:
1412
1406
  log(f"creating label: {name}")
1413
- label = eval_mod.create_case_label(client, workspace_id=workspace_id, name=name)
1407
+ label = eval_mod.create_case_label(client, name=name)
1414
1408
  labels_by_name[name.casefold()] = label
1415
1409
  created_labels.append(_label_summary(label))
1416
1410
 
@@ -1,10 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import math
3
4
  import os
4
5
 
5
6
  from .. import agents as agents_mod
6
7
  from .. import chats as chats_mod
7
8
  from .. import histories as hist_mod
9
+ from ..client import TransportError
8
10
  from ._util import log, print_json, strip_noisy_fields, truncate, write_json
9
11
 
10
12
 
@@ -74,10 +76,27 @@ def register(subparsers):
74
76
  p.add_argument("--user", default=None, help="external_user_id to associate with the history")
75
77
  p.add_argument("--message", action="append", required=True,
76
78
  help="User message to send. Repeat for multi-turn histories.")
79
+ p.add_argument("--timeout", type=float, default=120.0,
80
+ help="Per-message response timeout in seconds (default: 120).")
77
81
  p.add_argument("--out", default=None,
78
82
  help="Write complete create response/conversation artifact to this file; stdout stays compact.")
79
83
  p.set_defaults(func=run_create)
80
84
 
85
+ # codeer history send <id> --message ...
86
+ p = sub.add_parser("send", help="Continue an existing persisted conversation history")
87
+ p.add_argument("history_id", type=int)
88
+ p.add_argument("--agent", default=None,
89
+ help="Agent ID override (defaults to the history's agent or CODEER_AGENT_ID)")
90
+ p.add_argument("--user", default=None,
91
+ help="external_user_id override (defaults to the history's user)")
92
+ p.add_argument("--message", action="append", required=True,
93
+ help="User message to send. Repeat to append multiple turns.")
94
+ p.add_argument("--timeout", type=float, default=120.0,
95
+ help="Per-message response timeout in seconds (default: 120).")
96
+ p.add_argument("--out", default=None,
97
+ help="Write complete send response/conversation artifact to this file; stdout stays compact.")
98
+ p.set_defaults(func=run_send)
99
+
81
100
 
82
101
  def _parse_exclude(raw: str | None) -> list[str]:
83
102
  if not raw:
@@ -253,11 +272,51 @@ def _history_url(client, workspace_id: str, history_id: int) -> str:
253
272
  return f"{base}/workspaces/{workspace_id}/histories/{history_id}"
254
273
 
255
274
 
275
+ def _send_messages(
276
+ client,
277
+ *,
278
+ history_id: int,
279
+ agent_id: str,
280
+ external_user_id: str | None,
281
+ messages: list[str],
282
+ timeout: float,
283
+ ) -> list[dict]:
284
+ results = []
285
+ for idx, message in enumerate(messages, 1):
286
+ log(f"sending turn {idx}/{len(messages)}")
287
+ try:
288
+ result = chats_mod.send_published_agent_message(
289
+ client,
290
+ chat_id=history_id,
291
+ message=message,
292
+ agent_id=agent_id,
293
+ external_user_id=external_user_id,
294
+ stream=False,
295
+ timeout=timeout,
296
+ )
297
+ except TransportError as exc:
298
+ body = dict(exc.body) if isinstance(exc.body, dict) else {}
299
+ body.update({"history_id": history_id, "turn": idx, "turn_count": len(messages)})
300
+ raise TransportError(
301
+ f"Failed while sending turn {idx}/{len(messages)} to history {history_id}: {exc.message}",
302
+ body,
303
+ ) from exc
304
+ results.append(result)
305
+ return results
306
+
307
+
308
+ def _valid_timeout(timeout: float) -> bool:
309
+ return math.isfinite(timeout) and timeout > 0
310
+
311
+
256
312
  def run_create(args, client) -> int:
257
313
  agent_id = args.agent or os.environ.get("CODEER_AGENT_ID")
258
314
  if not agent_id:
259
315
  log("error: --agent is required or set CODEER_AGENT_ID")
260
316
  return 2
317
+ if not _valid_timeout(args.timeout):
318
+ log("error: --timeout must be a finite number greater than zero")
319
+ return 2
261
320
 
262
321
  workspace_id, _ = client.resolve_scope()
263
322
  title = args.title or (args.message[0].strip()[:80] if args.message else "CLI conversation")
@@ -269,19 +328,14 @@ def run_create(args, client) -> int:
269
328
  external_user_id=args.user,
270
329
  )
271
330
  history_id = chat["id"]
272
- message_results = []
273
-
274
- for idx, message in enumerate(args.message, 1):
275
- log(f"sending turn {idx}/{len(args.message)}")
276
- result = chats_mod.send_published_agent_message(
277
- client,
278
- chat_id=history_id,
279
- message=message,
280
- agent_id=agent_id,
281
- external_user_id=args.user,
282
- stream=False,
283
- )
284
- message_results.append(result)
331
+ message_results = _send_messages(
332
+ client,
333
+ history_id=history_id,
334
+ agent_id=agent_id,
335
+ external_user_id=args.user,
336
+ messages=args.message,
337
+ timeout=args.timeout,
338
+ )
285
339
 
286
340
  conversations = hist_mod.get_conversations(client, history_id)
287
341
  out = {
@@ -303,3 +357,61 @@ def run_create(args, client) -> int:
303
357
  "wrote_full_detail": bool(args.out),
304
358
  })
305
359
  return 0
360
+
361
+
362
+ def run_send(args, client) -> int:
363
+ if not _valid_timeout(args.timeout):
364
+ log("error: --timeout must be a finite number greater than zero")
365
+ return 2
366
+
367
+ history = hist_mod.get(client, args.history_id)
368
+ history_agent = history.get("agent") or {}
369
+ history_meta = history.get("meta") or {}
370
+ agent_id = (
371
+ args.agent
372
+ or history.get("agent_id")
373
+ or history_agent.get("id")
374
+ or history_meta.get("conversation_agent_id")
375
+ or os.environ.get("CODEER_AGENT_ID")
376
+ )
377
+ if not agent_id:
378
+ log("error: could not resolve agent from history; pass --agent or set CODEER_AGENT_ID")
379
+ return 2
380
+
381
+ external_user_id = (
382
+ args.user
383
+ if args.user is not None
384
+ else (
385
+ history.get("external_user_id")
386
+ or history_meta.get("external_user_id")
387
+ )
388
+ )
389
+ workspace_id, _ = client.resolve_scope()
390
+ message_results = _send_messages(
391
+ client,
392
+ history_id=args.history_id,
393
+ agent_id=agent_id,
394
+ external_user_id=external_user_id,
395
+ messages=args.message,
396
+ timeout=args.timeout,
397
+ )
398
+ conversations = hist_mod.get_conversations(client, args.history_id)
399
+ out = {
400
+ "agent_id": agent_id,
401
+ "history_id": args.history_id,
402
+ "external_user_id": external_user_id,
403
+ "url": _history_url(client, workspace_id, args.history_id),
404
+ "messages": message_results,
405
+ "conversations": conversations,
406
+ }
407
+ write_json(args.out, strip_noisy_fields(out))
408
+ print_json({
409
+ "agent_id": agent_id,
410
+ "history_id": args.history_id,
411
+ "external_user_id": external_user_id,
412
+ "url": out["url"],
413
+ "message_count": len(message_results),
414
+ "turn_count": len(conversations),
415
+ "wrote_full_detail": bool(args.out),
416
+ })
417
+ return 0
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from .. import models as models_mod
4
+ from ._util import print_json, strip_noisy_fields, write_json
5
+
6
+
7
+ def register(subparsers):
8
+ model = subparsers.add_parser("model", help="Available LLM models")
9
+ sub = model.add_subparsers(dest="action", required=True)
10
+
11
+ p = sub.add_parser("list", help="List active cloud LLM models available to agents")
12
+ p.add_argument("--type", choices=("text", "image"), default=None,
13
+ help="Filter by model type")
14
+ p.add_argument("--full", action="store_true",
15
+ help="Include modalities, pricing, and creation metadata")
16
+ p.add_argument("--out", default=None,
17
+ help="Write the complete model response to this file")
18
+ p.set_defaults(func=run_list)
19
+
20
+
21
+ def _model_summary(model: dict, *, full: bool = False) -> dict:
22
+ row = {
23
+ "display_name": model.get("display_name"),
24
+ "model_id": model.get("model_id"),
25
+ "provider": model.get("provider"),
26
+ "model_type": model.get("model_type"),
27
+ }
28
+ if full:
29
+ row.update({
30
+ "input_modalities": model.get("input_modalities") or [],
31
+ "input_credits_per_million_tokens": model.get("input_credits_per_million_tokens"),
32
+ "output_credits_per_million_tokens": model.get("output_credits_per_million_tokens"),
33
+ "created_at": model.get("created_at"),
34
+ })
35
+ return row
36
+
37
+
38
+ def run_list(args, client) -> int:
39
+ result = models_mod.list_available(client, model_type=args.type)
40
+ full_result = strip_noisy_fields(result)
41
+ write_json(args.out, full_result)
42
+ print_json([_model_summary(model, full=args.full) for model in result])
43
+ return 0
codeer_cli/eval_.py CHANGED
@@ -141,21 +141,20 @@ def replace_case_evaluator_infos(
141
141
 
142
142
  # --- case labels -----------------------------------------------------------
143
143
 
144
- def list_case_labels(client: CodeerClient, *, workspace_id: str) -> list[dict]:
145
- return client.get(f"/eval/workspaces/{workspace_id}/case-labels")
144
+ def list_case_labels(client: CodeerClient) -> list[dict]:
145
+ return client.get("/external/eval/case-labels")
146
146
 
147
147
 
148
148
  def create_case_label(
149
149
  client: CodeerClient,
150
150
  *,
151
- workspace_id: str,
152
151
  name: str,
153
152
  color: Optional[str] = None,
154
153
  ) -> dict:
155
154
  body: dict[str, Any] = {"name": name}
156
155
  if color is not None:
157
156
  body["color"] = color
158
- return client.post(f"/eval/workspaces/{workspace_id}/case-labels", json=body)
157
+ return client.post("/external/eval/case-labels", json=body)
159
158
 
160
159
 
161
160
  def update_case_label(
@@ -170,11 +169,11 @@ def update_case_label(
170
169
  body["name"] = name
171
170
  if color is not None:
172
171
  body["color"] = color
173
- return client.put(f"/eval/case-labels/{label_id}", json=body)
172
+ return client.put(f"/external/eval/case-labels/{label_id}", json=body)
174
173
 
175
174
 
176
175
  def delete_case_label(client: CodeerClient, *, label_id: str) -> dict:
177
- return client.delete(f"/eval/case-labels/{label_id}")
176
+ return client.delete(f"/external/eval/case-labels/{label_id}")
178
177
 
179
178
 
180
179
 
codeer_cli/models.py ADDED
@@ -0,0 +1,16 @@
1
+ """Available LLM model discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from .client import CodeerClient
8
+
9
+
10
+ def list_available(
11
+ client: CodeerClient,
12
+ *,
13
+ model_type: Optional[str] = None,
14
+ ) -> list[dict]:
15
+ params = {"model_type": model_type} if model_type else None
16
+ return client.get("/llm/models", params=params)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeer-cli
3
- Version: 0.1.8
3
+ Version: 0.1.10
4
4
  Summary: Command line tools for managing Codeer agents over the Codeer API.
5
5
  Project-URL: Homepage, https://www.codeer.ai
6
6
  Author: Codeer.AI
@@ -117,6 +117,35 @@ Validate setup before API work:
117
117
  codeer check
118
118
  ```
119
119
 
120
+ List the active cloud models without opening the Codeer web app:
121
+
122
+ ```bash
123
+ codeer model list --type text
124
+ ```
125
+
126
+ ## Agent human handoff
127
+
128
+ `codeer agent apply` accepts the same `human_handoff` object as the Agent API.
129
+ The dry-run validates it and shows whether handoff is enabled before any server
130
+ write:
131
+
132
+ ```json
133
+ {
134
+ "name": "Support Agent",
135
+ "system_prompt": "Help the user safely.",
136
+ "human_handoff": {
137
+ "enabled": true,
138
+ "idle_timeout_minutes": null,
139
+ "handoff_instructions": "Hand off when the user asks for a person."
140
+ }
141
+ }
142
+ ```
143
+
144
+ `idle_timeout_minutes` must be a positive integer or `null`. Human handoff only
145
+ becomes available in live published-agent conversations with a non-empty
146
+ `external_user_id`; editor Live Test conversations are internal and cannot
147
+ activate human mode.
148
+
120
149
  ## Upgrade and uninstall
121
150
 
122
151
  Upgrade the CLI:
@@ -143,9 +172,20 @@ Use this pattern during agent lifecycle work:
143
172
  ```bash
144
173
  codeer agent list
145
174
  codeer history list --agent <agent-id> --limit 50
175
+ codeer history create --agent <agent-id> --message "Review this plan" --timeout 120
176
+ codeer history send <history-id> --message "Use the recommended options" --timeout 120
146
177
  codeer eval run --agent <agent-id> --cases <case-ids> --evaluator <evaluator-id> --out .codeer/eval_run.json
147
178
  ```
148
179
 
180
+ `history create` and `history send` use the agent's current published version.
181
+ Their per-message timeout defaults to 120 seconds. If a write request times
182
+ out, inspect the history before retrying: the server may have completed the
183
+ turn after the client stopped waiting.
184
+
185
+ Eval case label commands always operate on the active API-key workspace. They
186
+ do not accept a workspace override; switch CLI profiles to target another
187
+ workspace.
188
+
149
189
  Flags:
150
190
 
151
191
  - `--full` prints bounded extra detail for human inspection. It is still
@@ -0,0 +1,25 @@
1
+ codeer_cli/__init__.py,sha256=z3rkUdVCt11c8QE3-YFkG5cFObM6QhPe2yKugA8k3aA,1915
2
+ codeer_cli/_validate.py,sha256=lqMwkhsBy1g0o5CINBp1HdBCjMawgUaEsZygjJaAPL0,6943
3
+ codeer_cli/agents.py,sha256=HWfz6QgU4wn07df5s3twxVJfAhb0vLJHIfxTjvDMUzU,6045
4
+ codeer_cli/chats.py,sha256=pww_AVByTh_AT2Td0gj2OomJrcw7D-cjDIpQ0ZQOlrM,2728
5
+ codeer_cli/cli.py,sha256=ghZMn7LUxDDvb3f82mDYW5hIFLk7dtlrCrGGP40Gygw,4637
6
+ codeer_cli/client.py,sha256=x-5sx06dgS6P8siKOTCVNM8JlBST5e6PsOD83KKkpN8,12361
7
+ codeer_cli/constants.py,sha256=D1pV3wCoqYybrKGKeoupYjjFWLfaFviKp1yL7oh6Qso,2323
8
+ codeer_cli/eval_.py,sha256=2Tz4qokctocZ0fqLYL8Sk9UV8yJ7p-9zYiUFljsRRCs,17478
9
+ codeer_cli/histories.py,sha256=tk28git_peX4x703CIDU8u72JtlGaytyrtlHfxlK-7A,5979
10
+ codeer_cli/kb.py,sha256=Ad4h65NByq5Rq5BTeMghLTKlWRhmOC2jxL0BaTGX3EM,10631
11
+ codeer_cli/models.py,sha256=Ama9fx8hyRMdysiUNA3Ty0sYIYwzFDkOcAZg-id3-vQ,362
12
+ codeer_cli/parse.py,sha256=qrjZn0MUTjGfucp4cwxy8Pt7WS-0x15kK5F7kWTY8Ps,21818
13
+ codeer_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ codeer_cli/commands/_util.py,sha256=X9-9cYgnBYz94hVG4Pe9SsI97DHNv3HE5_7GlPMn93I,1708
15
+ codeer_cli/commands/agent.py,sha256=6qtYcQYgdJE26V6Nw6parB_3RYAYXcOpPE9iJLuLGHo,15888
16
+ codeer_cli/commands/check.py,sha256=lTxolx1mIJ8jldPhJ5FXqie9nbCLVOO-sDPOHTSy1-w,3817
17
+ codeer_cli/commands/eval_cmd.py,sha256=Zlt_YIA3D5-Y8WL1HZ6PxZgL-TcwFTDoXvO2gv7yfOM,71989
18
+ codeer_cli/commands/history.py,sha256=bVBlCMStTvTxG23bggASHkHKuI0RMPdiXjcrs3U04yw,16081
19
+ codeer_cli/commands/kb.py,sha256=kVEinBVM6NN8_0djOqIQErh46dLmArwFngXMNzvGeAI,28345
20
+ codeer_cli/commands/model.py,sha256=91LDo_YeHi3POZV7KTCkoCcgtK5p87LwrHX1lZgfCps,1698
21
+ codeer_cli/commands/profile.py,sha256=IdlXC_6cqobsfN3JRrAnt-1OgBUsIFneS9QtR4Un6Kc,6521
22
+ codeer_cli-0.1.10.dist-info/METADATA,sha256=OFyEgLMBpMJ2cUkG58dei40u1BBtmSEF8HdPeyGbCek,7396
23
+ codeer_cli-0.1.10.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
24
+ codeer_cli-0.1.10.dist-info/entry_points.txt,sha256=-nXIrlm5SR5r7gg3y8AS0tN66MwmvNHsrlwLNQNGD50,47
25
+ codeer_cli-0.1.10.dist-info/RECORD,,
@@ -1,23 +0,0 @@
1
- codeer_cli/__init__.py,sha256=-0gL8upoSsLAnXAfcRrwqZYJbwG0knzQoFf94O7Nc7c,1817
2
- codeer_cli/_validate.py,sha256=pKUJa2TyTpERx5xmiYNZRn7tFqDxLZ2fF1rHAf1oz14,5415
3
- codeer_cli/agents.py,sha256=diodgiGhXlowEi8sbCzcSK1qSeCLF2fBe6QBs3Sq_x8,5617
4
- codeer_cli/chats.py,sha256=YVrZJhoa-d67o6tzX6riGXsbA-ehyhOxrZ8zRCcJNro,2675
5
- codeer_cli/cli.py,sha256=g-WR2D5MkaUdc13ZrpRCavXD1940CHE9eBELC034tic,4443
6
- codeer_cli/client.py,sha256=LpHVqf1IYNg1wFfIHnO9q4xg2h3IiGOitzCnvwB-Bcw,9809
7
- codeer_cli/constants.py,sha256=D1pV3wCoqYybrKGKeoupYjjFWLfaFviKp1yL7oh6Qso,2323
8
- codeer_cli/eval_.py,sha256=4borDzuU7DOqWi1XidfaELmp7eYjV2FH90uKXgGIDuM,17541
9
- codeer_cli/histories.py,sha256=tk28git_peX4x703CIDU8u72JtlGaytyrtlHfxlK-7A,5979
10
- codeer_cli/kb.py,sha256=Ad4h65NByq5Rq5BTeMghLTKlWRhmOC2jxL0BaTGX3EM,10631
11
- codeer_cli/parse.py,sha256=qrjZn0MUTjGfucp4cwxy8Pt7WS-0x15kK5F7kWTY8Ps,21818
12
- codeer_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- codeer_cli/commands/_util.py,sha256=X9-9cYgnBYz94hVG4Pe9SsI97DHNv3HE5_7GlPMn93I,1708
14
- codeer_cli/commands/agent.py,sha256=amvfVVrbPOkbYKCGvA6EJB30C-aY6WSdfR7u7FklXbs,14793
15
- codeer_cli/commands/check.py,sha256=lTxolx1mIJ8jldPhJ5FXqie9nbCLVOO-sDPOHTSy1-w,3817
16
- codeer_cli/commands/eval_cmd.py,sha256=V_6OptwWaQ8QLtzJeBqEtlHLUpYXWce1UneeMGPog14,72456
17
- codeer_cli/commands/history.py,sha256=Jv7t0GhSZcbZ8OuIXZT34CixXt7ECEVP3nZ-WW_Ya9E,12026
18
- codeer_cli/commands/kb.py,sha256=kVEinBVM6NN8_0djOqIQErh46dLmArwFngXMNzvGeAI,28345
19
- codeer_cli/commands/profile.py,sha256=IdlXC_6cqobsfN3JRrAnt-1OgBUsIFneS9QtR4Un6Kc,6521
20
- codeer_cli-0.1.8.dist-info/METADATA,sha256=dj78KQR8zotgqs8U_VaO5hmIF3ib8XfdGYLDyG5kLOo,5999
21
- codeer_cli-0.1.8.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
22
- codeer_cli-0.1.8.dist-info/entry_points.txt,sha256=-nXIrlm5SR5r7gg3y8AS0tN66MwmvNHsrlwLNQNGD50,47
23
- codeer_cli-0.1.8.dist-info/RECORD,,