codeer-cli 0.1.8__tar.gz → 0.1.10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/API_REFERENCE.md +34 -7
  2. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/PKG-INFO +41 -1
  3. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/README.md +40 -0
  4. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/pyproject.toml +1 -1
  5. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/src/codeer_cli/__init__.py +4 -3
  6. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/src/codeer_cli/_validate.py +42 -1
  7. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/src/codeer_cli/agents.py +9 -1
  8. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/src/codeer_cli/chats.py +2 -2
  9. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/src/codeer_cli/cli.py +9 -1
  10. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/src/codeer_cli/client.py +96 -24
  11. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/src/codeer_cli/commands/agent.py +26 -3
  12. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/src/codeer_cli/commands/eval_cmd.py +10 -16
  13. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/src/codeer_cli/commands/history.py +125 -13
  14. codeer_cli-0.1.10/src/codeer_cli/commands/model.py +43 -0
  15. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/src/codeer_cli/eval_.py +5 -6
  16. codeer_cli-0.1.10/src/codeer_cli/models.py +16 -0
  17. codeer_cli-0.1.10/tests/test_agent_handoff.py +153 -0
  18. codeer_cli-0.1.10/tests/test_client_transport.py +84 -0
  19. codeer_cli-0.1.10/tests/test_eval_labels.py +238 -0
  20. codeer_cli-0.1.10/tests/test_history_send.py +159 -0
  21. codeer_cli-0.1.10/tests/test_models.py +68 -0
  22. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/uv.lock +1 -1
  23. codeer_cli-0.1.8/tests/test_eval_labels.py +0 -101
  24. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/.gitignore +0 -0
  25. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/src/codeer_cli/commands/__init__.py +0 -0
  26. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/src/codeer_cli/commands/_util.py +0 -0
  27. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/src/codeer_cli/commands/check.py +0 -0
  28. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/src/codeer_cli/commands/kb.py +0 -0
  29. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/src/codeer_cli/commands/profile.py +0 -0
  30. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/src/codeer_cli/constants.py +0 -0
  31. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/src/codeer_cli/histories.py +0 -0
  32. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/src/codeer_cli/kb.py +0 -0
  33. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/src/codeer_cli/parse.py +0 -0
  34. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/tests/test_eval_pairs.py +0 -0
  35. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/tests/test_kb_nodes.py +0 -0
  36. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/tests/test_kb_ranges.py +0 -0
  37. {codeer_cli-0.1.8 → codeer_cli-0.1.10}/tests/test_util.py +0 -0
@@ -49,6 +49,23 @@ like `knowledge_node_ids`, `domain`, `agent_id`, `http_request` config.
49
49
 
50
50
  Limits: 10 tools per agent, ≤5 `call_agent`, ≤1 `memory`.
51
51
 
52
+ Agent create/update payloads may also include:
53
+
54
+ ```json
55
+ {
56
+ "human_handoff": {
57
+ "enabled": true,
58
+ "idle_timeout_minutes": null,
59
+ "handoff_instructions": "Hand off when the user asks for a person."
60
+ }
61
+ }
62
+ ```
63
+
64
+ `idle_timeout_minutes` must be greater than zero when provided. The runtime
65
+ only exposes the handoff tool to an external conversation with a non-empty
66
+ `external_user_id`, or to an evaluation run. Internal editor Live Test does not
67
+ activate human mode.
68
+
52
69
  ## Stage 2 — Knowledge bases
53
70
 
54
71
  Base path: `/organizations/{org_id}/workspaces/{ws_id}/knowledge_bases`
@@ -149,10 +166,10 @@ for the apply → test → publish workflow. Pass the draft `AgentHistory.id` fr
149
166
 
150
167
  | Method & path | Purpose |
151
168
  | --- | --- |
152
- | `GET /eval/workspaces/{workspace_id}/case-labels` | List reusable eval case labels |
153
- | `POST /eval/workspaces/{workspace_id}/case-labels` | Create reusable eval case label (`name`, `color?`) |
154
- | `PUT /eval/case-labels/{label_id}` | Update eval case label name/color |
155
- | `DELETE /eval/case-labels/{label_id}` | Delete eval case label and clear associations |
169
+ | `GET /external/eval/case-labels` | List reusable eval case labels in the API key workspace |
170
+ | `POST /external/eval/case-labels` | Create reusable eval case label (`name`, `color?`) in the API key workspace |
171
+ | `PUT /external/eval/case-labels/{label_id}` | Update an eval case label in the API key workspace |
172
+ | `DELETE /external/eval/case-labels/{label_id}` | Delete an eval case label and clear associations |
156
173
  | `POST /eval/cases` | Create case (`input`, `expected_output?`, `rubric?`, `label_ids?`); rubric = user-docs "Standard" |
157
174
  | `GET /eval/agents/{agent_id}/cases` | List cases for an agent |
158
175
  | `GET /eval/cases/{case_id}` | Read one |
@@ -171,8 +188,11 @@ for the apply → test → publish workflow. Pass the draft `AgentHistory.id` fr
171
188
  | `POST /eval/rubric` | Set/override the rubric for one (case, evaluator); also creates assignment |
172
189
  | `POST /eval/rubrics/batch` | **Read** rubrics for a batch of (case, evaluator) pairs |
173
190
 
174
- Eval case labels are workspace-scoped reusable objects. The case create/update
175
- payload uses `label_ids` (stringified label IDs), not freeform label names:
191
+ Eval case labels are workspace-scoped reusable objects. The external label
192
+ endpoints infer the workspace from the API key and do not accept a workspace
193
+ override; switch CLI profiles to operate on another workspace. The case
194
+ create/update payload uses `label_ids` (stringified label IDs), not freeform
195
+ label names:
176
196
 
177
197
  ```json
178
198
  {
@@ -247,12 +267,19 @@ the public CLI.
247
267
 
248
268
  | Method & path | Purpose |
249
269
  | --- | --- |
270
+ | `POST /chats` | Create a persisted history using an agent's current published version |
271
+ | `POST /chats/{id}/messages` | Append a turn to an existing persisted history using the current published version |
250
272
  | `GET /histories?agent_id=X&feedback_filter=improve_feedback&external_user_id=…` | List conversations with filters |
251
273
  | `GET /histories/{id}` | Read one history's metadata |
252
274
  | `GET /histories/{id}/conversations` | Full conversation turns incl. tool calls |
253
275
  | `POST /histories/{hid}/conversations/{cid}/feedbacks` | Leave freeform improvement feedback |
254
276
  | `POST /histories/{hid}/conversations/{cid}/score` | Numeric score |
255
277
 
278
+ The CLI exposes the first two operations as `codeer history create` and
279
+ `codeer history send`. Non-streaming message requests default to a 120-second
280
+ per-message timeout. A timeout has an uncertain write outcome, so read the
281
+ history before retrying to avoid duplicate turns.
282
+
256
283
  `feedback_filter` accepts the `FeedbackFilterType` enum values:
257
284
  `no_feedback`, `with_feedback`, `helpful_feedback`, `improve_feedback`.
258
285
 
@@ -270,7 +297,7 @@ Non-destructive: older versions stay in `GET /agents/{id}/histories`.
270
297
  | --- | --- |
271
298
  | `GET /accounts/me` | Sanity-check session, read workspace_organization_map |
272
299
  | `GET /organizations` | List orgs visible to the user |
273
- | `GET /llm/models` | List available LLM model IDs to use as `llm_model` |
300
+ | `GET /llm/models` | List available LLM model IDs to use as `llm_model` (`codeer model list`) |
274
301
  | `GET /retrieval/...` | Shared retrieval helpers (file upload for attachments, markdown conversion) |
275
302
 
276
303
  ---
@@ -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
@@ -99,6 +99,35 @@ Validate setup before API work:
99
99
  codeer check
100
100
  ```
101
101
 
102
+ List the active cloud models without opening the Codeer web app:
103
+
104
+ ```bash
105
+ codeer model list --type text
106
+ ```
107
+
108
+ ## Agent human handoff
109
+
110
+ `codeer agent apply` accepts the same `human_handoff` object as the Agent API.
111
+ The dry-run validates it and shows whether handoff is enabled before any server
112
+ write:
113
+
114
+ ```json
115
+ {
116
+ "name": "Support Agent",
117
+ "system_prompt": "Help the user safely.",
118
+ "human_handoff": {
119
+ "enabled": true,
120
+ "idle_timeout_minutes": null,
121
+ "handoff_instructions": "Hand off when the user asks for a person."
122
+ }
123
+ }
124
+ ```
125
+
126
+ `idle_timeout_minutes` must be a positive integer or `null`. Human handoff only
127
+ becomes available in live published-agent conversations with a non-empty
128
+ `external_user_id`; editor Live Test conversations are internal and cannot
129
+ activate human mode.
130
+
102
131
  ## Upgrade and uninstall
103
132
 
104
133
  Upgrade the CLI:
@@ -125,9 +154,20 @@ Use this pattern during agent lifecycle work:
125
154
  ```bash
126
155
  codeer agent list
127
156
  codeer history list --agent <agent-id> --limit 50
157
+ codeer history create --agent <agent-id> --message "Review this plan" --timeout 120
158
+ codeer history send <history-id> --message "Use the recommended options" --timeout 120
128
159
  codeer eval run --agent <agent-id> --cases <case-ids> --evaluator <evaluator-id> --out .codeer/eval_run.json
129
160
  ```
130
161
 
162
+ `history create` and `history send` use the agent's current published version.
163
+ Their per-message timeout defaults to 120 seconds. If a write request times
164
+ out, inspect the history before retrying: the server may have completed the
165
+ turn after the client stopped waiting.
166
+
167
+ Eval case label commands always operate on the active API-key workspace. They
168
+ do not accept a workspace override; switch CLI profiles to target another
169
+ workspace.
170
+
131
171
  Flags:
132
172
 
133
173
  - `--full` prints bounded extra detail for human inspection. It is still
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codeer-cli"
7
- version = "0.1.8"
7
+ version = "0.1.10"
8
8
  description = "Command line tools for managing Codeer agents over the Codeer API."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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",
@@ -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
 
@@ -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
 
@@ -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
-
@@ -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
@@ -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}")