python-agent-harness 1.5.6.0__tar.gz → 1.5.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. {python_agent_harness-1.5.6.0/python_agent_harness.egg-info → python_agent_harness-1.5.6.1}/PKG-INFO +93 -17
  2. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/README.md +92 -16
  3. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/pyproject.toml +1 -1
  4. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/__init__.py +1 -1
  5. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/core/agent.py +47 -5
  6. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/core/models.py +33 -0
  7. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/entry/cli.py +58 -11
  8. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/entry/controller.py +15 -0
  9. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/entry/headless.py +203 -22
  10. python_agent_harness-1.5.6.1/python_agent_harness/entry/server.py +1003 -0
  11. python_agent_harness-1.5.6.1/python_agent_harness/entry/view.py +110 -0
  12. python_agent_harness-1.5.6.1/python_agent_harness/io/text_filter.py +262 -0
  13. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/session/config.py +48 -11
  14. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/session/session.py +67 -8
  15. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tools/read.py +0 -1
  16. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tui/core.py +16 -2
  17. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tui/render.py +2 -26
  18. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1/python_agent_harness.egg-info}/PKG-INFO +93 -17
  19. python_agent_harness-1.5.6.0/python_agent_harness/entry/server.py +0 -534
  20. python_agent_harness-1.5.6.0/python_agent_harness/entry/view.py +0 -51
  21. python_agent_harness-1.5.6.0/python_agent_harness/io/text_filter.py +0 -84
  22. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/LICENSE +0 -0
  23. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/__main__.py +0 -0
  24. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/core/__init__.py +0 -0
  25. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/core/context_manager.py +0 -0
  26. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/core/planmode.py +0 -0
  27. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/core/subagent.py +0 -0
  28. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/core/token_estimator.py +0 -0
  29. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/core/tool_runner.py +0 -0
  30. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/entry/__init__.py +0 -0
  31. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/io/__init__.py +0 -0
  32. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/io/attachments.py +0 -0
  33. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/io/diffrender.py +0 -0
  34. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/io/persistence.py +0 -0
  35. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/llm/__init__.py +0 -0
  36. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/llm/client.py +0 -0
  37. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/lsp/__init__.py +0 -0
  38. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/lsp/client.py +0 -0
  39. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/lsp/config.py +0 -0
  40. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/lsp/manager.py +0 -0
  41. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/mcp/__init__.py +0 -0
  42. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/mcp/client.py +0 -0
  43. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/mcp/config.py +0 -0
  44. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/mcp/manager.py +0 -0
  45. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/prompts/__init__.py +0 -0
  46. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/prompts/agent.md +0 -0
  47. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/prompts/agents/reviewer.md +0 -0
  48. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/prompts/build-switch.md +0 -0
  49. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/prompts/commands/explain.md +0 -0
  50. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/prompts/compact.md +0 -0
  51. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/prompts/core.py +0 -0
  52. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/prompts/initialize.md +0 -0
  53. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/prompts/plan-mode.md +0 -0
  54. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/prompts/plan.md +0 -0
  55. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/prompts/review.md +0 -0
  56. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/prompts/subagent.md +0 -0
  57. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/prompts/summary.md +0 -0
  58. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  59. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/prompts/title.md +0 -0
  60. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/session/__init__.py +0 -0
  61. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/session/commands.py +0 -0
  62. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tools/__init__.py +0 -0
  63. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tools/agent_tool.py +0 -0
  64. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tools/base.py +0 -0
  65. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tools/bash.py +0 -0
  66. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tools/diffapply.py +0 -0
  67. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tools/edit.py +0 -0
  68. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tools/edit_mac.py +0 -0
  69. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tools/filesystem.py +0 -0
  70. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tools/glob.py +0 -0
  71. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tools/glob_mac.py +0 -0
  72. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tools/grep.py +0 -0
  73. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tools/grep_mac.py +0 -0
  74. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tools/insert.py +0 -0
  75. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tools/lsp.py +0 -0
  76. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tools/mcp.py +0 -0
  77. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tools/mkdir.py +0 -0
  78. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tools/planexit.py +0 -0
  79. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tools/question.py +0 -0
  80. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tools/skill.py +0 -0
  81. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tools/todo.py +0 -0
  82. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tools/write.py +0 -0
  83. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tui/__init__.py +0 -0
  84. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tui/commands.py +0 -0
  85. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness/tui/input.py +0 -0
  86. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness.egg-info/SOURCES.txt +0 -0
  87. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  88. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness.egg-info/entry_points.txt +0 -0
  89. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness.egg-info/requires.txt +0 -0
  90. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/python_agent_harness.egg-info/top_level.txt +0 -0
  91. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/setup.cfg +0 -0
  92. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/tests/test_invariants.py +0 -0
  93. {python_agent_harness-1.5.6.0 → python_agent_harness-1.5.6.1}/tests/test_scenarios.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.6.0
3
+ Version: 1.5.6.1
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
5
  Author-email: Huming Chen <chenhuming@gmail.com>
6
6
  License-Expression: MIT
@@ -222,6 +222,8 @@ All LLM settings live in a single JSON configuration file. Environment variables
222
222
  - **`paths.context_path` / `paths.skill_path`** — locations from which to load context files and skills. When unset, the project-local `<project>/contexts` and `<project>/skills` directories are used.
223
223
  - **`lsp.servers`** — optional per-extension LSP server overrides for the `LSP` code-intelligence tool. Keys are file extensions (e.g. `.py`, `.cpp`); each value has a `command` (the server argv) and an optional `language_id` (defaults to the extension without its dot). These layer on top of the built-in `DEFAULT_SERVERS` table in `lsp/manager.py`; an entry for an existing extension replaces its default. The server binary must be on `PATH`.
224
224
  - **`mcp.servers`** — MCP server configuration. Requires the `[mcp]` extra. Each server supports `transport`, `command`, `args`, `env`, `url`, `headers`, `parallel`, `timeout`, and `enabled`.
225
+ - **`headless`** — optional server-side limits for unattended `headless` runs: `max_rounds` caps LLM rounds and `timeout` caps wall-clock seconds. `--max-rounds` / `--timeout` override these per call; values `<= 0` or `null` disable. Unset = unlimited (interactive behavior).
226
+ - **`serve`** — the same two keys for the resident `serve` server, applied to **each** run rather than the process: the server is resident, so every `submit` gets a fresh budget. Enforced in-process so a driving host cannot raise its own ceiling — the ceiling belongs to whoever starts the sandbox. A tripped budget ends the run with a normal `result` line carrying `errors[].code` of `budget` or `timeout` plus the token usage consumed. `--max-rounds` / `--timeout` override these; values `<= 0` or `null` disable. Unset = unlimited.
225
227
  - **Configuration precedence** — code defaults < config file < `OPENAI_*` environment variables. Sub-agent settings also support `OPENAI_SUBAGENT_*` (`_BASE_URL`, `_API_KEY`, `_MODEL`).
226
228
  - **Custom config** — use `--config PATH` or `PYTHON_AGENT_HARNESS_CONFIG`.
227
229
  - **LLM logging** — request and response bodies are logged as JSON to `/tmp/python-agent-harness-<date>-<id>.json`. Set `LLM_LOG_DIR` to change the directory. The log path is printed at startup.
@@ -271,9 +273,16 @@ Interactive prompts are auto-answered (`confirm` → yes, `ask` →
271
273
  one `{"type": ...}` object per line: `start` (echoes the prompt and
272
274
  submit warnings), `delta` (streamed text chunks), `notify` (tool and
273
275
  status events, with `kind`/`data`), `log`, and a final `result` (the
274
- filtered answer plus any `errors`). Diagnostics (restore/model notes,
275
- a plain-text echo of error events) still go to stderr, so one pipe
276
- carries the structured stream. Exit codes are unchanged.
276
+ filtered answer plus any `errors`). Every line carries `protocol`
277
+ (the wire schema version — reject an unknown version before
278
+ consuming events) and `seq` (a per-stream monotonic counter for
279
+ drop/reorder detection). The `result` line reports `errors` as a
280
+ structured list of `{"code", "message"}` objects (codes: `budget`,
281
+ `timeout`, `nothing`, `restore`, `no_answer`, `unknown`, ...) with an
282
+ `error_messages` flat-string mirror for simple drivers. Diagnostics
283
+ (restore/model notes, a plain-text echo of error events) still go to
284
+ stderr, so one pipe carries the structured stream. Exit codes are
285
+ unchanged.
277
286
 
278
287
  ```sh
279
288
  python-agent-harness headless "fix it" --json | jq -c 'select(.type=="result")'
@@ -286,6 +295,7 @@ Interactive prompts are auto-answered (`confirm` → yes, `ask` →
286
295
 
287
296
  ```sh
288
297
  python-agent-harness serve [--project DIR] [--answer-timeout SECONDS]
298
+ [--max-rounds N] [--timeout SECONDS]
289
299
  ```
290
300
 
291
301
  A persistent, bidirectional runtime boundary for hosting applications
@@ -306,23 +316,87 @@ Protocol (one JSON object per line):
306
316
 
307
317
  ```
308
318
  host → agent: {"op": "submit", "prompt": ..., "run_id": ...}
309
- {"op": "answer", "run_id": ..., "answers": [...]}
319
+ {"op": "answer", "run_id": ..., "answers": [...], "ask_id": ...}
310
320
  {"op": "cancel", "run_id": ...}
321
+ {"op": "hello", "protocol_versions": [...]}
311
322
  {"op": "ping"} | {"op": "shutdown"}
312
- agent → host: {"type": "ready"} first line
313
- {"seq": N, "type": "start"|"delta"|"notify"|"log", "run_id": ...}
314
- {"seq": N, "type": "result", "run_id": ..., "answer": ...,
315
- "errors": [...], "usage": {...}, "cancelled": bool}
316
- {"type": "error", "error": ...} protocol failures
323
+ ... any op may carry "op_id", echoed on what it causes
324
+ agent → host: {"protocol": V, "type": "ready", "pid": ...,
325
+ "protocol_version": V, "capabilities": [...]} first line
326
+ {"protocol": V, "type": "hello", "protocol_version": V,
327
+ "capabilities": [...], "op_id": ...} negotiation reply
328
+ {"protocol": V, "seq": N, "type": "start"|"delta"|"notify"|"log",
329
+ "run_id": ...}
330
+ {"protocol": V, "seq": N, "type": "result", "run_id": ...,
331
+ "answer": ..., "errors": [{"code", "message"}],
332
+ "error_messages": [...], "usage": {...}, "cancelled": bool}
333
+ {"protocol": V, "type": "error",
334
+ "error": {"code": "protocol", "message": ...},
335
+ "message": ...} protocol failures
317
336
  ```
318
337
 
338
+ Version negotiation runs both ways. `ready` announces what the build
339
+ speaks — a single integer a host can only accept or reject wholesale.
340
+ `hello` is the other direction: the host states the versions *it* can
341
+ parse and the server confirms a shared one or refuses with a
342
+ `protocol` error, so a mismatch is settled once, before any run,
343
+ rather than surfacing as misparsed events. With one line shape today
344
+ the only outcomes are "agreed" or "no overlap", but it is the hook a
345
+ future version needs in order to downgrade instead of breaking an
346
+ older host. Both halves are advertised as the `hello` capability.
347
+
348
+ There is no generic `ack`: each op is acknowledged by its own effect
349
+ on the stream. Failures are the exception, because an effect that
350
+ never happened cannot be correlated — so an op may carry an `op_id`,
351
+ echoed on the `error` (or `pong`) it causes. A host that pipelined an
352
+ `answer` and a `cancel` can then tell which one was refused instead of
353
+ seeing two indistinguishable error lines. Advertised as the `op_id`
354
+ capability; omitting the field keeps the older behaviour.
355
+
319
356
  A mid-run question arrives as a `notify` with `kind: "ask"` (data has
320
- `kind: "ask"|"confirm"`); reply with `answer`. One run at a time; a
321
- `submit` while one is active is rejected with an `error` line.
322
- `--answer-timeout SECONDS` bounds how long a pending question waits
323
- for the host's answer (default: forever). The result line's shape is
324
- identical to `headless --json`'s, so a driver can speak both
325
- protocols with one parser.
357
+ `kind: "ask"|"confirm"` and an `ask_id`); reply with `answer`, echoing
358
+ the `ask_id` so the reply is matched to its question. A mismatched id
359
+ is refused rather than applied to whatever is pending — otherwise a
360
+ reply sent for a question that has since timed out would resolve the
361
+ *next* one. Omitting `ask_id` keeps the older behaviour, except once
362
+ some ask in the run has timed out, when an uncorrelated answer is
363
+ ambiguous and is refused.
364
+
365
+ One run at a time; a `submit` while one is active is rejected with an
366
+ `error` line. `--answer-timeout SECONDS` bounds how long a pending
367
+ question waits for the host's answer (default: forever). The result
368
+ line's shape is identical to `headless --json`'s, so a driver can
369
+ speak both protocols with one parser.
370
+
371
+ `ready` carries a `capabilities` list naming the features the build
372
+ supports, so a host can adapt instead of inferring a feature's absence
373
+ from events that never arrive. A `notify` of kind `usage` reports the
374
+ run's running `{input, output, rounds}` after each round, so a host can
375
+ meter mid-run and `cancel` a run that outruns its budget rather than
376
+ learning the cost only from the terminal `result`. Classified
377
+ failures carry their code from the raise site (`errors[].code` of
378
+ `budget` or `timeout`), so a driver never has to match words in a
379
+ human-readable message.
380
+
381
+ `--max-rounds N` and `--timeout SECONDS` (or the config file's `serve`
382
+ section) bound **each** run rather than the process — the resident
383
+ server grants every `submit` a fresh budget. Both are off by default.
384
+ They are deliberately not fields on the `submit` op: `serve` sandboxes
385
+ untrusted agent code on a host's behalf, so the ceiling belongs to
386
+ whoever starts the sandbox, not to the caller. A tripped budget
387
+ unwinds the agent loop through its normal path, so the run still ends
388
+ with a `result` line carrying `errors[].code` of `budget` or `timeout`
389
+ plus the token usage consumed — a host bills and reports it like any
390
+ other outcome.
391
+
392
+ `timeout` is **cooperative** — checked between rounds, not by a
393
+ watchdog — so it bounds work at those checkpoints but does not
394
+ forcibly interrupt a blocking operation mid-round (a hung tool, a slow
395
+ network read, or a run parked on a mid-run question awaiting a human
396
+ answer). Use `--answer-timeout` to cap how long an ask waits, and rely
397
+ on the hosting process's own exec watchdog (which sends `cancel` then
398
+ kills) as the hard wall-clock stop for anything the cooperative budget
399
+ cannot reach.
326
400
 
327
401
  ### Slash commands
328
402
 
@@ -340,6 +414,7 @@ protocols with one parser.
340
414
  | `/clear` | Start a fresh conversation |
341
415
  | `/model [name]` | Switch LLM profiles; `default` restores the session's original model |
342
416
  | `/agent [name]` | Switch agent system prompt; `default` restores the built-in `agent.md` |
417
+ | `/help` | List the available commands |
343
418
  | `/exit` | Quit |
344
419
 
345
420
  Custom commands from `prompts/commands/*.md` are registered as slash commands as well (TUI only).
@@ -384,12 +459,13 @@ Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13 on Linux a
384
459
  make test # unit tests
385
460
  venv/bin/pip install -e ".[dev]" # development tools
386
461
  venv/bin/ruff check . # lint
462
+ venv/bin/ruff format --check . # formatting (CI blocks on this too)
387
463
  venv/bin/pyright # type checking
388
464
  venv/bin/python -m build # build sdist + wheel
389
465
  venv/bin/pip-audit # dependency audit
390
466
  ```
391
467
 
392
- CI blocks on Ruff and Pyright failures.
468
+ CI blocks on Ruff (lint **and** `format --check`) and Pyright failures.
393
469
 
394
470
  ## Design philosophy
395
471
 
@@ -188,6 +188,8 @@ All LLM settings live in a single JSON configuration file. Environment variables
188
188
  - **`paths.context_path` / `paths.skill_path`** — locations from which to load context files and skills. When unset, the project-local `<project>/contexts` and `<project>/skills` directories are used.
189
189
  - **`lsp.servers`** — optional per-extension LSP server overrides for the `LSP` code-intelligence tool. Keys are file extensions (e.g. `.py`, `.cpp`); each value has a `command` (the server argv) and an optional `language_id` (defaults to the extension without its dot). These layer on top of the built-in `DEFAULT_SERVERS` table in `lsp/manager.py`; an entry for an existing extension replaces its default. The server binary must be on `PATH`.
190
190
  - **`mcp.servers`** — MCP server configuration. Requires the `[mcp]` extra. Each server supports `transport`, `command`, `args`, `env`, `url`, `headers`, `parallel`, `timeout`, and `enabled`.
191
+ - **`headless`** — optional server-side limits for unattended `headless` runs: `max_rounds` caps LLM rounds and `timeout` caps wall-clock seconds. `--max-rounds` / `--timeout` override these per call; values `<= 0` or `null` disable. Unset = unlimited (interactive behavior).
192
+ - **`serve`** — the same two keys for the resident `serve` server, applied to **each** run rather than the process: the server is resident, so every `submit` gets a fresh budget. Enforced in-process so a driving host cannot raise its own ceiling — the ceiling belongs to whoever starts the sandbox. A tripped budget ends the run with a normal `result` line carrying `errors[].code` of `budget` or `timeout` plus the token usage consumed. `--max-rounds` / `--timeout` override these; values `<= 0` or `null` disable. Unset = unlimited.
191
193
  - **Configuration precedence** — code defaults < config file < `OPENAI_*` environment variables. Sub-agent settings also support `OPENAI_SUBAGENT_*` (`_BASE_URL`, `_API_KEY`, `_MODEL`).
192
194
  - **Custom config** — use `--config PATH` or `PYTHON_AGENT_HARNESS_CONFIG`.
193
195
  - **LLM logging** — request and response bodies are logged as JSON to `/tmp/python-agent-harness-<date>-<id>.json`. Set `LLM_LOG_DIR` to change the directory. The log path is printed at startup.
@@ -237,9 +239,16 @@ Interactive prompts are auto-answered (`confirm` → yes, `ask` →
237
239
  one `{"type": ...}` object per line: `start` (echoes the prompt and
238
240
  submit warnings), `delta` (streamed text chunks), `notify` (tool and
239
241
  status events, with `kind`/`data`), `log`, and a final `result` (the
240
- filtered answer plus any `errors`). Diagnostics (restore/model notes,
241
- a plain-text echo of error events) still go to stderr, so one pipe
242
- carries the structured stream. Exit codes are unchanged.
242
+ filtered answer plus any `errors`). Every line carries `protocol`
243
+ (the wire schema version — reject an unknown version before
244
+ consuming events) and `seq` (a per-stream monotonic counter for
245
+ drop/reorder detection). The `result` line reports `errors` as a
246
+ structured list of `{"code", "message"}` objects (codes: `budget`,
247
+ `timeout`, `nothing`, `restore`, `no_answer`, `unknown`, ...) with an
248
+ `error_messages` flat-string mirror for simple drivers. Diagnostics
249
+ (restore/model notes, a plain-text echo of error events) still go to
250
+ stderr, so one pipe carries the structured stream. Exit codes are
251
+ unchanged.
243
252
 
244
253
  ```sh
245
254
  python-agent-harness headless "fix it" --json | jq -c 'select(.type=="result")'
@@ -252,6 +261,7 @@ Interactive prompts are auto-answered (`confirm` → yes, `ask` →
252
261
 
253
262
  ```sh
254
263
  python-agent-harness serve [--project DIR] [--answer-timeout SECONDS]
264
+ [--max-rounds N] [--timeout SECONDS]
255
265
  ```
256
266
 
257
267
  A persistent, bidirectional runtime boundary for hosting applications
@@ -272,23 +282,87 @@ Protocol (one JSON object per line):
272
282
 
273
283
  ```
274
284
  host → agent: {"op": "submit", "prompt": ..., "run_id": ...}
275
- {"op": "answer", "run_id": ..., "answers": [...]}
285
+ {"op": "answer", "run_id": ..., "answers": [...], "ask_id": ...}
276
286
  {"op": "cancel", "run_id": ...}
287
+ {"op": "hello", "protocol_versions": [...]}
277
288
  {"op": "ping"} | {"op": "shutdown"}
278
- agent → host: {"type": "ready"} first line
279
- {"seq": N, "type": "start"|"delta"|"notify"|"log", "run_id": ...}
280
- {"seq": N, "type": "result", "run_id": ..., "answer": ...,
281
- "errors": [...], "usage": {...}, "cancelled": bool}
282
- {"type": "error", "error": ...} protocol failures
289
+ ... any op may carry "op_id", echoed on what it causes
290
+ agent → host: {"protocol": V, "type": "ready", "pid": ...,
291
+ "protocol_version": V, "capabilities": [...]} first line
292
+ {"protocol": V, "type": "hello", "protocol_version": V,
293
+ "capabilities": [...], "op_id": ...} negotiation reply
294
+ {"protocol": V, "seq": N, "type": "start"|"delta"|"notify"|"log",
295
+ "run_id": ...}
296
+ {"protocol": V, "seq": N, "type": "result", "run_id": ...,
297
+ "answer": ..., "errors": [{"code", "message"}],
298
+ "error_messages": [...], "usage": {...}, "cancelled": bool}
299
+ {"protocol": V, "type": "error",
300
+ "error": {"code": "protocol", "message": ...},
301
+ "message": ...} protocol failures
283
302
  ```
284
303
 
304
+ Version negotiation runs both ways. `ready` announces what the build
305
+ speaks — a single integer a host can only accept or reject wholesale.
306
+ `hello` is the other direction: the host states the versions *it* can
307
+ parse and the server confirms a shared one or refuses with a
308
+ `protocol` error, so a mismatch is settled once, before any run,
309
+ rather than surfacing as misparsed events. With one line shape today
310
+ the only outcomes are "agreed" or "no overlap", but it is the hook a
311
+ future version needs in order to downgrade instead of breaking an
312
+ older host. Both halves are advertised as the `hello` capability.
313
+
314
+ There is no generic `ack`: each op is acknowledged by its own effect
315
+ on the stream. Failures are the exception, because an effect that
316
+ never happened cannot be correlated — so an op may carry an `op_id`,
317
+ echoed on the `error` (or `pong`) it causes. A host that pipelined an
318
+ `answer` and a `cancel` can then tell which one was refused instead of
319
+ seeing two indistinguishable error lines. Advertised as the `op_id`
320
+ capability; omitting the field keeps the older behaviour.
321
+
285
322
  A mid-run question arrives as a `notify` with `kind: "ask"` (data has
286
- `kind: "ask"|"confirm"`); reply with `answer`. One run at a time; a
287
- `submit` while one is active is rejected with an `error` line.
288
- `--answer-timeout SECONDS` bounds how long a pending question waits
289
- for the host's answer (default: forever). The result line's shape is
290
- identical to `headless --json`'s, so a driver can speak both
291
- protocols with one parser.
323
+ `kind: "ask"|"confirm"` and an `ask_id`); reply with `answer`, echoing
324
+ the `ask_id` so the reply is matched to its question. A mismatched id
325
+ is refused rather than applied to whatever is pending — otherwise a
326
+ reply sent for a question that has since timed out would resolve the
327
+ *next* one. Omitting `ask_id` keeps the older behaviour, except once
328
+ some ask in the run has timed out, when an uncorrelated answer is
329
+ ambiguous and is refused.
330
+
331
+ One run at a time; a `submit` while one is active is rejected with an
332
+ `error` line. `--answer-timeout SECONDS` bounds how long a pending
333
+ question waits for the host's answer (default: forever). The result
334
+ line's shape is identical to `headless --json`'s, so a driver can
335
+ speak both protocols with one parser.
336
+
337
+ `ready` carries a `capabilities` list naming the features the build
338
+ supports, so a host can adapt instead of inferring a feature's absence
339
+ from events that never arrive. A `notify` of kind `usage` reports the
340
+ run's running `{input, output, rounds}` after each round, so a host can
341
+ meter mid-run and `cancel` a run that outruns its budget rather than
342
+ learning the cost only from the terminal `result`. Classified
343
+ failures carry their code from the raise site (`errors[].code` of
344
+ `budget` or `timeout`), so a driver never has to match words in a
345
+ human-readable message.
346
+
347
+ `--max-rounds N` and `--timeout SECONDS` (or the config file's `serve`
348
+ section) bound **each** run rather than the process — the resident
349
+ server grants every `submit` a fresh budget. Both are off by default.
350
+ They are deliberately not fields on the `submit` op: `serve` sandboxes
351
+ untrusted agent code on a host's behalf, so the ceiling belongs to
352
+ whoever starts the sandbox, not to the caller. A tripped budget
353
+ unwinds the agent loop through its normal path, so the run still ends
354
+ with a `result` line carrying `errors[].code` of `budget` or `timeout`
355
+ plus the token usage consumed — a host bills and reports it like any
356
+ other outcome.
357
+
358
+ `timeout` is **cooperative** — checked between rounds, not by a
359
+ watchdog — so it bounds work at those checkpoints but does not
360
+ forcibly interrupt a blocking operation mid-round (a hung tool, a slow
361
+ network read, or a run parked on a mid-run question awaiting a human
362
+ answer). Use `--answer-timeout` to cap how long an ask waits, and rely
363
+ on the hosting process's own exec watchdog (which sends `cancel` then
364
+ kills) as the hard wall-clock stop for anything the cooperative budget
365
+ cannot reach.
292
366
 
293
367
  ### Slash commands
294
368
 
@@ -306,6 +380,7 @@ protocols with one parser.
306
380
  | `/clear` | Start a fresh conversation |
307
381
  | `/model [name]` | Switch LLM profiles; `default` restores the session's original model |
308
382
  | `/agent [name]` | Switch agent system prompt; `default` restores the built-in `agent.md` |
383
+ | `/help` | List the available commands |
309
384
  | `/exit` | Quit |
310
385
 
311
386
  Custom commands from `prompts/commands/*.md` are registered as slash commands as well (TUI only).
@@ -350,12 +425,13 @@ Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13 on Linux a
350
425
  make test # unit tests
351
426
  venv/bin/pip install -e ".[dev]" # development tools
352
427
  venv/bin/ruff check . # lint
428
+ venv/bin/ruff format --check . # formatting (CI blocks on this too)
353
429
  venv/bin/pyright # type checking
354
430
  venv/bin/python -m build # build sdist + wheel
355
431
  venv/bin/pip-audit # dependency audit
356
432
  ```
357
433
 
358
- CI blocks on Ruff and Pyright failures.
434
+ CI blocks on Ruff (lint **and** `format --check`) and Pyright failures.
359
435
 
360
436
  ## Design philosophy
361
437
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-agent-harness"
7
- version = "1.5.6.0"
7
+ version = "1.5.6.1"
8
8
  description = "A lightweight, hackable mini-OpenCode written in Python."
9
9
  authors = [
10
10
  {name = "Huming Chen", email = "chenhuming@gmail.com"}
@@ -8,7 +8,7 @@ from .mcp.config import MCPConfig, MCPServerConfig
8
8
  from .mcp.manager import MCPManager
9
9
  from .session.session import Session
10
10
 
11
- __version__ = "1.5.6.0"
11
+ __version__ = "1.5.6.1"
12
12
 
13
13
  __all__ = [
14
14
  "Client",
@@ -43,7 +43,7 @@ from ..llm.client import LLMClient
43
43
  from ..session import config
44
44
  from ..tools.base import PendingToolResult
45
45
  from .context_manager import ContextManager
46
- from .models import Message, ToolCall
46
+ from .models import Message, ToolCall, display_args
47
47
  from .token_estimator import context_window_for, estimate_payload_tokens
48
48
  from .tool_runner import (
49
49
  NIL_RESULT_PLACEHOLDER, # noqa: F401 (re-exported for backward compat)
@@ -150,6 +150,16 @@ class AgentLoop:
150
150
  # from cancellation: a cancelled run with no successor still
151
151
  # owns the session and may salvage its partial history.
152
152
  self._run_gen = session.run_generation
153
+ # Tag THIS thread (the one that will drive the loop — the run
154
+ # worker, or a sub-agent's own thread) with the run it belongs
155
+ # to, so the session can drop events from a superseded run
156
+ # instead of attributing them to the current one. A detached
157
+ # sub-agent thread can outlive its run; see
158
+ # `Session._emit_superseded`. Suppressed for session doubles
159
+ # that predate the hook.
160
+ mark = getattr(session, "mark_emit_generation", None)
161
+ if callable(mark):
162
+ mark(self._run_gen)
153
163
 
154
164
  def _is_cancelled(self) -> bool:
155
165
  """Whether THIS run must stop (cancelled or superseded).
@@ -205,14 +215,19 @@ class AgentLoop:
205
215
  if self.top_level:
206
216
  self.error = "Error: round budget exhausted before the run finished"
207
217
  self.info["error"] = self.error
208
- self.session.notify("error", self.error)
218
+ # The code is assigned HERE, where the outcome is known.
219
+ # A driver reading the result line gets "budget" because
220
+ # the budget check said so — not because a downstream
221
+ # classifier matched words in this sentence, which would
222
+ # silently degrade to "unknown" if it were reworded.
223
+ self.session.notify("error", {"code": "budget", "message": self.error})
209
224
  else:
210
225
  self.info["budget"] = True
211
226
  return True
212
227
  if self._deadline_exceeded():
213
228
  self.error = f"Error: {_deadline_message(self.timeout)}"
214
229
  self.info["error"] = self.error
215
- self.session.notify("error", self.error)
230
+ self.session.notify("error", {"code": "timeout", "message": self.error})
216
231
  return True
217
232
  return False
218
233
 
@@ -503,8 +518,12 @@ class AgentLoop:
503
518
  return
504
519
 
505
520
  def safe_delta(text: str) -> None:
506
- if not self._is_cancelled() and session.on_delta is not None:
507
- session.on_delta(text)
521
+ # Snapshot: a host that swaps views per run unwires
522
+ # on_delta at run teardown, so re-reading it after the
523
+ # None-test could call None (see Session.notify).
524
+ on_delta = session.on_delta
525
+ if not self._is_cancelled() and on_delta is not None:
526
+ on_delta(text)
508
527
 
509
528
  try:
510
529
  # sub-agents are one-shot tasks: they must not see (or
@@ -581,6 +600,20 @@ class AgentLoop:
581
600
  totals["input"] = totals.get("input", 0) + usage.input_tokens
582
601
  totals["output"] = totals.get("output", 0) + usage.output_tokens
583
602
  totals["rounds"] = totals.get("rounds", 0) + 1
603
+ snapshot = {
604
+ "input": totals["input"],
605
+ "output": totals["output"],
606
+ "rounds": totals["rounds"],
607
+ }
608
+ # Publish the running total so a host can meter DURING the
609
+ # run instead of only billing the terminal result line.
610
+ # Without this a run that outlives its user's allowance
611
+ # cannot be stopped until it finishes on its own — the host
612
+ # can now cancel mid-run. Emitted from the top-level loop
613
+ # only (the snapshot already includes sub-agent tokens, so
614
+ # per-sub-agent lines would add noise, not information).
615
+ if self.top_level:
616
+ session.notify("usage", snapshot)
584
617
 
585
618
  # persist the assistant response in the conversation history
586
619
  # (text and/or tool calls) so later turns and the UI see it;
@@ -621,6 +654,15 @@ class AgentLoop:
621
654
  self.session.last_messages = list(self.messages)
622
655
  names = [tc.name for tc in self.pending]
623
656
  self.session.notify("tool_start", names)
657
+ # Additive companion event: the same round, but with each
658
+ # call's arguments so a hosting UI can show WHAT is about to
659
+ # run ("Bash(command='ls -la')"), which bare names cannot
660
+ # convey. Kept separate from tool_start so the payload shape
661
+ # of the documented protocol stays backward compatible.
662
+ self.session.notify(
663
+ "tool_calls",
664
+ [{"name": tc.name, "args": display_args(tc.arguments)} for tc in self.pending],
665
+ )
624
666
  self._execute_pending()
625
667
 
626
668
  def _handle_tret(self) -> None:
@@ -80,6 +80,39 @@ class ToolCall:
80
80
  elapsed: float | None = None # execution wall-time in seconds (TUI display)
81
81
 
82
82
 
83
+ def arg_repr(value: Any, limit: int = 100) -> str:
84
+ """repr() of a tool-call argument, truncated to LIMIT chars total.
85
+
86
+ Long values (a long Bash command, a big JSON payload) are cut with
87
+ an ellipsis instead of being dropped from the label entirely.
88
+ """
89
+ r = repr(value)
90
+ if len(r) <= limit:
91
+ return r
92
+ return r[: limit - 1] + "…"
93
+
94
+
95
+ def display_args(arguments: dict[str, Any] | str, limit: int = 100) -> dict[str, str]:
96
+ """Tool-call arguments as short display strings, keyed by name.
97
+
98
+ ``arguments`` arrives either already decoded or as the raw JSON
99
+ string the model emitted (unparseable when the model truncated it).
100
+ ``content`` is skipped: a Write/Edit payload is far too large for a
101
+ one-line label, and it is shown as a diff instead.
102
+
103
+ Values are truncated, so the result is a display artifact -- never
104
+ the data a tool is executed with.
105
+ """
106
+ if isinstance(arguments, str):
107
+ try:
108
+ arguments = json.loads(arguments)
109
+ except (json.JSONDecodeError, ValueError):
110
+ return {}
111
+ if not isinstance(arguments, dict):
112
+ return {}
113
+ return {k: arg_repr(v, limit) for k, v in arguments.items() if k != "content"}
114
+
115
+
83
116
  @dataclass
84
117
  class Message:
85
118
  """One conversation message in OpenAI-compatible format.
@@ -195,6 +195,31 @@ def cmd_run(args: argparse.Namespace) -> int:
195
195
  session.close()
196
196
 
197
197
 
198
+ def _resolve_budgets(
199
+ args: argparse.Namespace, cfg_limits: tuple[int | None, float | None]
200
+ ) -> tuple[int | None, float | None]:
201
+ """Resolve ``(max_rounds, timeout)`` for an unattended run.
202
+
203
+ Precedence: an explicit CLI flag wins over the config file's
204
+ section default, which wins over unlimited. A flag of 0/negative
205
+ is an explicit "disable", so it overrides a configured budget
206
+ rather than falling back to it. Shared by ``headless`` and
207
+ ``serve``, whose budget flags are identical.
208
+ """
209
+ cfg_max_rounds, cfg_timeout = cfg_limits
210
+ max_rounds = getattr(args, "max_rounds", None)
211
+ if max_rounds is None:
212
+ max_rounds = cfg_max_rounds
213
+ elif max_rounds <= 0:
214
+ max_rounds = None
215
+ timeout = getattr(args, "timeout", None)
216
+ if timeout is None:
217
+ timeout = cfg_timeout
218
+ elif timeout <= 0:
219
+ timeout = None
220
+ return max_rounds, timeout
221
+
222
+
198
223
  def cmd_headless(args: argparse.Namespace) -> int:
199
224
  project_dir = getattr(args, "project", None) or os.getcwd()
200
225
  session = make_session_with_mcp(
@@ -211,17 +236,7 @@ def cmd_headless(args: argparse.Namespace) -> int:
211
236
  runner = run_headless_jsonl if getattr(args, "json", False) else run_headless
212
237
  # Unattended budgets: explicit flags win over the config file's
213
238
  # headless section (0/negative disables the budget entirely).
214
- cfg_max_rounds, cfg_timeout = config.load_headless_limits(args.config)
215
- max_rounds = getattr(args, "max_rounds", None)
216
- if max_rounds is None:
217
- max_rounds = cfg_max_rounds
218
- elif max_rounds <= 0:
219
- max_rounds = None
220
- timeout = getattr(args, "timeout", None)
221
- if timeout is None:
222
- timeout = cfg_timeout
223
- elif timeout <= 0:
224
- timeout = None
239
+ max_rounds, timeout = _resolve_budgets(args, config.load_headless_limits(args.config))
225
240
  return runner(
226
241
  session,
227
242
  prompt,
@@ -245,9 +260,15 @@ def cmd_serve(args: argparse.Namespace) -> int:
245
260
  try:
246
261
  from .server import run_serve
247
262
 
263
+ # Per-run budgets: explicit flags win over the config file's
264
+ # serve section (0/negative disables). Applied to EACH submit
265
+ # — the process is resident, so every run gets a fresh budget.
266
+ max_rounds, timeout = _resolve_budgets(args, config.load_serve_limits(args.config))
248
267
  return run_serve(
249
268
  session,
250
269
  answer_timeout=float(getattr(args, "answer_timeout", 0.0) or 0.0),
270
+ max_rounds=max_rounds,
271
+ timeout=timeout,
251
272
  )
252
273
  finally:
253
274
  session.close()
@@ -344,6 +365,14 @@ def cmd_config(args: argparse.Namespace) -> int:
344
365
  print(
345
366
  f"headless: max_rounds={max_rounds or '(unlimited)'}, timeout={timeout or '(unlimited)'}s"
346
367
  )
368
+ serve_rounds, serve_timeout = config.load_serve_limits(args.path)
369
+ if serve_rounds is None and serve_timeout is None:
370
+ print("serve: (unlimited — set serve.max_rounds/timeout or use --max-rounds/--timeout)")
371
+ else:
372
+ print(
373
+ f"serve: max_rounds={serve_rounds or '(unlimited)'}, "
374
+ f"timeout={serve_timeout or '(unlimited)'}s (per run)"
375
+ )
347
376
  return 0
348
377
 
349
378
 
@@ -468,6 +497,24 @@ def build_parser() -> argparse.ArgumentParser:
468
497
  help="give up waiting for a host answer after N seconds (0 = wait "
469
498
  'forever, the default; the host can answer via {"op": "answer"})',
470
499
  )
500
+ p_serve.add_argument(
501
+ "--max-rounds",
502
+ metavar="N",
503
+ type=int,
504
+ default=None,
505
+ help="cap EACH run at N LLM rounds (per-submit budget, not a "
506
+ "process cap; default: config serve.max_rounds, else unlimited)",
507
+ )
508
+ p_serve.add_argument(
509
+ "--timeout",
510
+ metavar="SECONDS",
511
+ type=float,
512
+ default=None,
513
+ help="wall-clock limit for EACH run in seconds (per-submit budget, "
514
+ "cooperative — checked between rounds, does not interrupt a run "
515
+ "blocked on a mid-run question; default: config serve.timeout, else "
516
+ "unlimited)",
517
+ )
471
518
  return parser
472
519
 
473
520
 
@@ -83,6 +83,21 @@ class Controller:
83
83
  self.session.confirm_fn = view.confirm
84
84
  self.session.ask_fn = view.ask
85
85
 
86
+ def detach_view(self) -> None:
87
+ """Unwire the session's callbacks (no view attached).
88
+
89
+ The counterpart of ``attach_view``, for a host that swaps views
90
+ per run (``serve``): once a run's view is detached, a worker
91
+ still unwinding from that run emits into nothing instead of
92
+ into whichever view happens to be attached next — its events
93
+ would otherwise be attributed to a different run.
94
+ """
95
+ self.session.on_delta = None
96
+ self.session.notify_fn = None
97
+ self.session.log_fn = None
98
+ self.session.confirm_fn = None
99
+ self.session.ask_fn = None
100
+
86
101
  # ------------------------------------------------------------------
87
102
  # read-only state accessors
88
103
  # ------------------------------------------------------------------