codeer-cli 0.1.9__py3-none-any.whl → 0.1.11__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,7 +18,7 @@ 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
21
+ from ._validate import HumanHandoffValidationError, ToolValidationError
22
22
  from .client import AuthError, CodeerClient, CodeerError, TransportError
23
23
  from .parse import (
24
24
  AgentSummary,
@@ -44,6 +44,7 @@ from .parse import (
44
44
 
45
45
  __all__ = [
46
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
@@ -1,15 +1,10 @@
1
- """Chat creation and SSE-streamed agent responses — the Live Test surface.
2
-
3
- The send_message() path is the main dogfood driver: open a chat against a
4
- specific agent version (unpublished draft is fine) and consume the SSE stream
5
- to get tool calls, reasoning, and final text back.
6
- """
1
+ """Chat V2 creation, structured SSE responses, and persisted message reads."""
7
2
 
8
3
  from __future__ import annotations
9
4
 
10
5
  from typing import Any, Iterator, List, Optional
11
6
 
12
- from .client import CodeerClient
7
+ from .client import CodeerClient, TransportError
13
8
 
14
9
 
15
10
  def create(
@@ -19,12 +14,13 @@ def create(
19
14
  title: Optional[str] = None,
20
15
  external_user_id: Optional[str] = None,
21
16
  ) -> dict:
22
- body: dict[str, Any] = {"agent_id": agent_id}
23
- if title is not None:
24
- body["name"] = title
17
+ body: dict[str, Any] = {
18
+ "agent_id": agent_id,
19
+ "name": title or "CLI conversation",
20
+ }
25
21
  if external_user_id is not None:
26
22
  body["external_user_id"] = external_user_id
27
- return client.post("/chats", json=body)
23
+ return client.post("/chats", api_version="v2", json=body)
28
24
 
29
25
 
30
26
  def send_published_agent_message(
@@ -35,7 +31,7 @@ def send_published_agent_message(
35
31
  agent_id: str,
36
32
  external_user_id: Optional[str] = None,
37
33
  attachment_ids: Optional[List[str]] = None,
38
- stream: bool = False,
34
+ stream: bool = True,
39
35
  timeout: Optional[float] = None,
40
36
  ) -> Iterator[dict] | dict:
41
37
  """Send a user message through the API-key external chat flow.
@@ -51,8 +47,14 @@ def send_published_agent_message(
51
47
 
52
48
  path = f"/chats/{chat_id}/messages"
53
49
  if stream:
54
- return client.stream_sse("POST", path, json=body)
55
- return client.post(path, json=body, timeout=timeout)
50
+ return client.stream_sse(
51
+ "POST",
52
+ path,
53
+ api_version="v2",
54
+ json=body,
55
+ timeout=timeout,
56
+ )
57
+ return client.post(path, api_version="v2", json=body, timeout=timeout)
56
58
 
57
59
 
58
60
  def send_message(
@@ -63,25 +65,137 @@ def send_message(
63
65
  agent_history_id: str,
64
66
  attachment_ids: Optional[List[str]] = None,
65
67
  stream: bool = True,
68
+ timeout: Optional[float] = None,
66
69
  ) -> Iterator[dict] | dict:
67
- """Send a user message; yield SSE events (if stream=True) or return the final payload.
70
+ """Use legacy Chat V1 to pin an unpublished agent version.
68
71
 
69
- ``agent_history_id`` is required — this is how you pin Live Test to a specific
70
- (possibly unpublished) agent version without affecting production users.
72
+ Chat V2's API-key external flow only accepts the published ``agent_id``.
73
+ Keep this low-level compatibility helper on V1 until V2 supports external
74
+ ``agent_history_id`` pinning. It remains streaming by default.
71
75
  """
72
- body: dict[str, Any] = {"message": message, "agent_history_id": agent_history_id}
76
+ body: dict[str, Any] = {
77
+ "message": message,
78
+ "agent_history_id": agent_history_id,
79
+ "stream": stream,
80
+ }
73
81
  if attachment_ids:
74
82
  body["attachment_ids"] = attachment_ids
75
83
 
76
84
  path = f"/chats/{chat_id}/messages"
77
85
  if stream:
78
- return client.stream_sse("POST", path, json=body)
79
- return client.post(path, json=body)
86
+ return client.stream_sse(
87
+ "POST",
88
+ path,
89
+ json=body,
90
+ timeout=timeout,
91
+ )
92
+ return client.post(path, json=body, timeout=timeout)
80
93
 
81
94
 
82
- def list_messages(client: CodeerClient, chat_id: int) -> list[dict]:
83
- return client.get(f"/chats/{chat_id}/messages")
95
+ def collect_stream(events: Iterator[dict]) -> dict:
96
+ """Collect a Chat V2 SSE stream and require an explicit completion event."""
97
+ raw_events: list[dict] = []
98
+ parts: list[dict] = []
99
+ interactions: list[dict] = []
100
+ text_deltas: list[str] = []
101
+ final_text: str | None = None
102
+ response_id: str | None = None
103
+ conversation_group_id: str | None = None
104
+ updated_title: str | None = None
105
+ completed = False
106
+
107
+ for event in events:
108
+ data = event.get("data")
109
+ if data == "[DONE]":
110
+ continue
111
+ raw_events.append(event)
112
+ if not isinstance(data, dict):
113
+ continue
114
+
115
+ event_type = str(data.get("type") or event.get("event") or "message")
116
+ response_id = str(data.get("response_id") or response_id or "") or None
117
+ conversation_group_id = str(
118
+ data.get("conversation_group_id") or conversation_group_id or ""
119
+ ) or None
120
+
121
+ if event_type == "response.part.delta":
122
+ delta = data.get("delta")
123
+ if data.get("part_kind") == "text" and isinstance(delta, str):
124
+ text_deltas.append(delta)
125
+ elif event_type in ("response.part.created", "response.part.completed"):
126
+ part = data.get("part")
127
+ if isinstance(part, dict):
128
+ parts.append({"event": event_type, "part": part})
129
+ content = part.get("content")
130
+ if (
131
+ event_type == "response.part.completed"
132
+ and part.get("part_kind") == "text"
133
+ and isinstance(content, dict)
134
+ and isinstance(content.get("content"), str)
135
+ ):
136
+ final_text = content["content"]
137
+ elif event_type in ("response.interaction.created", "response.interaction.resolved"):
138
+ interactions.append(data)
139
+ elif event_type == "response.chat.title.updated":
140
+ name = data.get("name")
141
+ if isinstance(name, str):
142
+ updated_title = name
143
+ elif event_type == "response.failed":
144
+ message = data.get("message") or "Chat V2 stream failed"
145
+ raise TransportError(
146
+ str(message),
147
+ {
148
+ "code": data.get("code"),
149
+ "response_id": response_id,
150
+ "conversation_group_id": conversation_group_id,
151
+ "outcome_uncertain": True,
152
+ "events": raw_events,
153
+ },
154
+ )
155
+ elif event_type == "response.completed":
156
+ completed = True
157
+
158
+ if not completed:
159
+ raise TransportError(
160
+ "Chat V2 stream ended before response.completed. Inspect the history before retrying.",
161
+ {
162
+ "response_id": response_id,
163
+ "conversation_group_id": conversation_group_id,
164
+ "outcome_uncertain": True,
165
+ "events": raw_events,
166
+ },
167
+ )
168
+
169
+ return {
170
+ "stream": True,
171
+ "completed": True,
172
+ "response_id": response_id,
173
+ "conversation_group_id": conversation_group_id,
174
+ "final_text": final_text if final_text is not None else "".join(text_deltas),
175
+ "updated_title": updated_title,
176
+ "parts": parts,
177
+ "interactions": interactions,
178
+ "events": raw_events,
179
+ }
180
+
181
+
182
+ def list_messages(
183
+ client: CodeerClient,
184
+ chat_id: int,
185
+ *,
186
+ external_user_id: Optional[str] = None,
187
+ limit: int = 500,
188
+ ) -> dict:
189
+ params: dict[str, Any] = {"limit": limit, "offset": 0}
190
+ if external_user_id is not None:
191
+ params["external_user_id"] = external_user_id
192
+ return client.get(
193
+ f"/chats/{chat_id}/messages",
194
+ api_version="v2",
195
+ params=params,
196
+ )
84
197
 
85
198
 
86
199
  def list_chats(client: CodeerClient) -> list[dict]:
200
+ """List chats through the legacy v1 endpoint; Chat V2 has no list route."""
87
201
  return client.get("/chats")
codeer_cli/cli.py CHANGED
@@ -2,6 +2,7 @@
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
8
  codeer history list|get|conversations|negative-feedback|create|send
@@ -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
@@ -12,11 +12,12 @@ from __future__ import annotations
12
12
  import json as json_lib
13
13
  import os
14
14
  from dataclasses import dataclass
15
- from typing import Any, Iterable, Iterator, Mapping, Optional
15
+ from typing import Any, Iterable, Iterator, Literal, Mapping, Optional
16
16
 
17
17
  import httpx
18
18
 
19
19
  DEFAULT_CODEER_API_BASE = "https://api.codeer.ai"
20
+ ApiVersion = Literal["v1", "v2"]
20
21
 
21
22
 
22
23
  class CodeerError(RuntimeError):
@@ -154,13 +155,14 @@ class CodeerClient:
154
155
  method: str,
155
156
  path: str,
156
157
  *,
158
+ api_version: ApiVersion = "v1",
157
159
  params: Optional[Mapping[str, Any]] = None,
158
160
  json: Any = None,
159
161
  files: Any = None,
160
162
  data: Any = None,
161
163
  timeout: Optional[float] = None,
162
164
  ) -> Any:
163
- url = path if path.startswith("http") else f"/api/v1{path if path.startswith('/') else '/' + path}"
165
+ url = _api_url(path, api_version=api_version)
164
166
  request_kwargs: dict[str, Any] = {}
165
167
  if timeout is not None:
166
168
  request_kwargs["timeout"] = timeout
@@ -206,17 +208,28 @@ class CodeerClient:
206
208
  method: str,
207
209
  path: str,
208
210
  *,
211
+ api_version: ApiVersion = "v1",
209
212
  params: Optional[Mapping[str, Any]] = None,
210
213
  json: Any = None,
214
+ timeout: Optional[float] = None,
211
215
  ) -> Iterator[dict]:
212
216
  """Yield parsed SSE events from a streaming endpoint (e.g. POST /chats/{id}/messages).
213
217
 
214
218
  Each event is a dict like ``{"event": "message", "data": <parsed-json-or-str>}``.
215
219
  """
216
- url = path if path.startswith("http") else f"/api/v1{path if path.startswith('/') else '/' + path}"
220
+ url = _api_url(path, api_version=api_version)
217
221
  method_upper = method.upper()
222
+ request_kwargs: dict[str, Any] = {}
223
+ if timeout is not None:
224
+ request_kwargs["timeout"] = timeout
218
225
  try:
219
- with self._client.stream(method_upper, url, params=params, json=json) as r:
226
+ with self._client.stream(
227
+ method_upper,
228
+ url,
229
+ params=params,
230
+ json=json,
231
+ **request_kwargs,
232
+ ) as r:
220
233
  if r.status_code >= 400:
221
234
  body = r.read().decode("utf-8", "replace")
222
235
  self._raise_for_error(r.status_code, body)
@@ -244,7 +257,7 @@ class CodeerClient:
244
257
  method_upper,
245
258
  path,
246
259
  exc,
247
- timeout_seconds=self.timeout,
260
+ timeout_seconds=timeout if timeout is not None else self.timeout,
248
261
  ) from exc
249
262
  except httpx.RequestError as exc:
250
263
  raise self._transport_error(method_upper, path, exc) from exc
@@ -314,6 +327,18 @@ def _maybe_json(raw: str) -> Any:
314
327
  return raw
315
328
 
316
329
 
330
+ def _api_url(path: str, *, api_version: ApiVersion = "v1") -> str:
331
+ """Resolve a relative domain path without forcing every API onto one version."""
332
+ if path.startswith("http"):
333
+ return path
334
+ normalized = path if path.startswith("/") else f"/{path}"
335
+ if normalized.startswith("/api/"):
336
+ return normalized
337
+ if api_version not in ("v1", "v2"):
338
+ raise ValueError(f"Unsupported API version: {api_version}")
339
+ return f"/api/{api_version}{normalized}"
340
+
341
+
317
342
  def _workspace_names(profile: Mapping[str, Any]) -> dict[str, str]:
318
343
  names: dict[str, str] = {}
319
344
  for ws in profile.get("workspaces") or []:
@@ -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}")
@@ -76,8 +76,8 @@ def register(subparsers):
76
76
  p.add_argument("--user", default=None, help="external_user_id to associate with the history")
77
77
  p.add_argument("--message", action="append", required=True,
78
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).")
79
+ p.add_argument("--timeout", type=float, default=240.0,
80
+ help="Per-message SSE read timeout in seconds (default: 240).")
81
81
  p.add_argument("--out", default=None,
82
82
  help="Write complete create response/conversation artifact to this file; stdout stays compact.")
83
83
  p.set_defaults(func=run_create)
@@ -91,8 +91,8 @@ def register(subparsers):
91
91
  help="external_user_id override (defaults to the history's user)")
92
92
  p.add_argument("--message", action="append", required=True,
93
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).")
94
+ p.add_argument("--timeout", type=float, default=240.0,
95
+ help="Per-message SSE read timeout in seconds (default: 240).")
96
96
  p.add_argument("--out", default=None,
97
97
  help="Write complete send response/conversation artifact to this file; stdout stays compact.")
98
98
  p.set_defaults(func=run_send)
@@ -285,15 +285,19 @@ def _send_messages(
285
285
  for idx, message in enumerate(messages, 1):
286
286
  log(f"sending turn {idx}/{len(messages)}")
287
287
  try:
288
- result = chats_mod.send_published_agent_message(
288
+ stream = chats_mod.send_published_agent_message(
289
289
  client,
290
290
  chat_id=history_id,
291
291
  message=message,
292
292
  agent_id=agent_id,
293
293
  external_user_id=external_user_id,
294
- stream=False,
294
+ stream=True,
295
295
  timeout=timeout,
296
296
  )
297
+ if isinstance(stream, dict):
298
+ result = stream
299
+ else:
300
+ result = chats_mod.collect_stream(stream)
297
301
  except TransportError as exc:
298
302
  body = dict(exc.body) if isinstance(exc.body, dict) else {}
299
303
  body.update({"history_id": history_id, "turn": idx, "turn_count": len(messages)})
@@ -337,14 +341,19 @@ def run_create(args, client) -> int:
337
341
  timeout=args.timeout,
338
342
  )
339
343
 
340
- conversations = hist_mod.get_conversations(client, history_id)
344
+ chat_messages = chats_mod.list_messages(
345
+ client,
346
+ history_id,
347
+ external_user_id=args.user,
348
+ )
349
+ conversation_parts = chat_messages.get("messages") or []
341
350
  out = {
342
351
  "agent_id": agent_id,
343
352
  "history_id": history_id,
344
353
  "external_user_id": args.user,
345
354
  "url": _history_url(client, workspace_id, history_id),
346
355
  "messages": message_results,
347
- "conversations": conversations,
356
+ "conversation_parts": conversation_parts,
348
357
  }
349
358
  write_json(args.out, strip_noisy_fields(out))
350
359
  print_json({
@@ -353,7 +362,7 @@ def run_create(args, client) -> int:
353
362
  "external_user_id": args.user,
354
363
  "url": out["url"],
355
364
  "message_count": len(message_results),
356
- "turn_count": len(conversations),
365
+ "part_count": len(conversation_parts),
357
366
  "wrote_full_detail": bool(args.out),
358
367
  })
359
368
  return 0
@@ -395,14 +404,19 @@ def run_send(args, client) -> int:
395
404
  messages=args.message,
396
405
  timeout=args.timeout,
397
406
  )
398
- conversations = hist_mod.get_conversations(client, args.history_id)
407
+ chat_messages = chats_mod.list_messages(
408
+ client,
409
+ args.history_id,
410
+ external_user_id=external_user_id,
411
+ )
412
+ conversation_parts = chat_messages.get("messages") or []
399
413
  out = {
400
414
  "agent_id": agent_id,
401
415
  "history_id": args.history_id,
402
416
  "external_user_id": external_user_id,
403
417
  "url": _history_url(client, workspace_id, args.history_id),
404
418
  "messages": message_results,
405
- "conversations": conversations,
419
+ "conversation_parts": conversation_parts,
406
420
  }
407
421
  write_json(args.out, strip_noisy_fields(out))
408
422
  print_json({
@@ -411,7 +425,7 @@ def run_send(args, client) -> int:
411
425
  "external_user_id": external_user_id,
412
426
  "url": out["url"],
413
427
  "message_count": len(message_results),
414
- "turn_count": len(conversations),
428
+ "part_count": len(conversation_parts),
415
429
  "wrote_full_detail": bool(args.out),
416
430
  })
417
431
  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/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)
codeer_cli/parse.py CHANGED
@@ -9,21 +9,20 @@ Parsers are deliberately tolerant: missing/extra fields don't raise, casing
9
9
  mismatches (``FILE`` vs ``file``) are normalized to lowercase. They are NOT a
10
10
  schema validator.
11
11
 
12
- KEY GAP, called out here so you don't waste time:
12
+ LEGACY V1 HISTORY GAP, called out here so you don't waste time:
13
13
 
14
- Tool **arguments** (e.g. the regex passed to ``list_kb_files`` or the query
15
- passed to ``retrieve_context_objs``) and **outputs** are not persisted on the
16
- Conversation row. They flow over the WebSocket during execution and are
17
- dropped after the assistant turn is saved. What you CAN recover from a
18
- history is:
14
+ Tool **arguments** and **outputs** are not available on the legacy
15
+ ``Conversation`` row returned by ``/histories/{id}/conversations``. What you
16
+ CAN recover from that legacy history shape is:
19
17
 
20
18
  - tool name + call id (regex over ``content``: ``<tool id=...>name</tool>``)
21
19
  - per-call token usage (``meta.token_usage.tool_calls[]``)
22
20
  - retrieved primary sources (``primary_sources[]`` — the end-to-end trace)
23
21
  - assistant's final text (``content`` with tool markers stripped)
24
22
 
25
- If you need the raw tool args, you must capture them at execution time via
26
- the chat SSE stream, not from history reads.
23
+ Chat V2 structured SSE and ``GET /api/v2/chats/{id}/messages`` expose
24
+ persisted tool-call and tool-return parts. Use those surfaces when raw tool
25
+ I/O or exact event order matters.
27
26
  """
28
27
 
29
28
  from __future__ import annotations
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeer-cli
3
- Version: 0.1.9
3
+ Version: 0.1.11
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,15 +172,17 @@ 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
146
- codeer history create --agent <agent-id> --message "Review this plan" --timeout 120
147
- codeer history send <history-id> --message "Use the recommended options" --timeout 120
175
+ codeer history create --agent <agent-id> --message "Review this plan" --timeout 240
176
+ codeer history send <history-id> --message "Use the recommended options" --timeout 240
148
177
  codeer eval run --agent <agent-id> --cases <case-ids> --evaluator <evaluator-id> --out .codeer/eval_run.json
149
178
  ```
150
179
 
151
180
  `history create` and `history send` use the agent's current published version.
152
- Their per-message timeout defaults to 120 seconds. If a write request times
153
- out, inspect the history before retrying: the server may have completed the
154
- turn after the client stopped waiting.
181
+ They use Chat V2 structured SSE with `stream: true`; their per-message read
182
+ timeout defaults to 240 seconds. Success requires a `response.completed`
183
+ event. If the stream times out, reports `response.failed`, or disconnects
184
+ early, inspect the history before retrying: the server may already have
185
+ persisted the turn.
155
186
 
156
187
  Eval case label commands always operate on the active API-key workspace. They
157
188
  do not accept a workspace override; switch CLI profiles to target another
@@ -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=HaQYqbwv5Kq0RgBTKaa9vNYMlsBQonh85-v8yw_rJW8,6609
5
+ codeer_cli/cli.py,sha256=ghZMn7LUxDDvb3f82mDYW5hIFLk7dtlrCrGGP40Gygw,4637
6
+ codeer_cli/client.py,sha256=apUycjK-OvHEquAj58FqOy8FHmHjA6K9dScaYQ4M-VU,13175
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=-txMtIxPfeg_i4McEskVKxo8p4438gKS-sFTI38RoBs,21772
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=qb0YXggbJuSahDp-l2A6Abj3g-LpIXWzgHadTA-4ZIU,16500
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.11.dist-info/METADATA,sha256=tPOpQ2LTjMvWLk_KjwzXOKtJWr8Iq3XV0j0HEiScI88,7520
23
+ codeer_cli-0.1.11.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
24
+ codeer_cli-0.1.11.dist-info/entry_points.txt,sha256=-nXIrlm5SR5r7gg3y8AS0tN66MwmvNHsrlwLNQNGD50,47
25
+ codeer_cli-0.1.11.dist-info/RECORD,,
@@ -1,23 +0,0 @@
1
- codeer_cli/__init__.py,sha256=slzDQ-Le6vl5PrimV6ZKmcDcNsQ7my7yG-Is1JVlbi4,1851
2
- codeer_cli/_validate.py,sha256=pKUJa2TyTpERx5xmiYNZRn7tFqDxLZ2fF1rHAf1oz14,5415
3
- codeer_cli/agents.py,sha256=diodgiGhXlowEi8sbCzcSK1qSeCLF2fBe6QBs3Sq_x8,5617
4
- codeer_cli/chats.py,sha256=pww_AVByTh_AT2Td0gj2OomJrcw7D-cjDIpQ0ZQOlrM,2728
5
- codeer_cli/cli.py,sha256=tdHbAEqtfCWFVT1iecfWKh61P1IvjG59F9hfC4Srr0c,4455
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/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=Zlt_YIA3D5-Y8WL1HZ6PxZgL-TcwFTDoXvO2gv7yfOM,71989
17
- codeer_cli/commands/history.py,sha256=bVBlCMStTvTxG23bggASHkHKuI0RMPdiXjcrs3U04yw,16081
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.9.dist-info/METADATA,sha256=XlAf4gazUUSZ8uk9YRBxOYCiJ-C13Tru02G548cqI5M,6605
21
- codeer_cli-0.1.9.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
22
- codeer_cli-0.1.9.dist-info/entry_points.txt,sha256=-nXIrlm5SR5r7gg3y8AS0tN66MwmvNHsrlwLNQNGD50,47
23
- codeer_cli-0.1.9.dist-info/RECORD,,