hx-cli 0.2.10__tar.gz → 0.2.12__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.
- {hx_cli-0.2.10 → hx_cli-0.2.12}/CHANGELOG.md +39 -1
- {hx_cli-0.2.10 → hx_cli-0.2.12}/PKG-INFO +9 -3
- {hx_cli-0.2.10 → hx_cli-0.2.12}/README.md +8 -2
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/__init__.py +1 -1
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/cli.py +18 -25
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/events.py +3 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/loop.py +149 -36
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/messages.py +58 -1
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/session.py +18 -5
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/title.py +14 -1
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/bash.py +23 -5
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/app.py +4 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/commands.py +1 -1
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/renderers.py +5 -3
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/runtime.py +39 -12
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/views/blocks.py +17 -1
- {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/stub.py +59 -2
- {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/terminal.py +11 -2
- {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/test_commands.py +172 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/test_tools.py +91 -1
- {hx_cli-0.2.10 → hx_cli-0.2.12}/.github/workflows/ci.yml +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/.gitignore +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/examples/hooks/check.sh +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/install.sh +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/pyproject.toml +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/agents/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/agents/definitions.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/agents/subagent.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/auth/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/auth/oauth/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/auth/oauth/browser.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/auth/oauth/callback.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/auth/oauth/codex.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/auth/oauth/devin.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/auth/oauth/pkce.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/auth/resolve.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/auth/store.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/config.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/checkpoints.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/compaction.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/context.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/images.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/lateinject.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/usage.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/docs.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/frontmatter.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/git.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/hooks/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/hooks/engine.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/hooks/spec.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/keys.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/mcp/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/mcp/client.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/mcp/manager.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/mcp/oauth.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/net.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/paths.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/permissions/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/permissions/engine.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/permissions/parser.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/permissions/sandbox.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/base.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/codex.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/codex_catalogue.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/devin.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/devin_catalogue.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/devin_wire.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/models.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/openrouter.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/protowire.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/registry.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/responses_codec.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/skills/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/skills/loader.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/skills/runtime.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/ansi.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/buffer.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/component.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/demo.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/editor.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/keydecode.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/loop.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/markdown.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/modifiers.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/primitives.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/sanitize.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/screen.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/syntax.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/terminal.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/undo.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/width.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/word_nav.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/anchors.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/base.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/edit.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/glob.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/grep.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/output.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/read.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/registry.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/symbols.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/task.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/todo.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/websearch.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/write.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/trace.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/clipboard.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/format.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/fuzzy.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/glyphs.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/killring.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/limits.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/paint.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/roles.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/theme.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/theme_json.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/themes/ansi.json +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/themes/dark.json +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/themes/light.json +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/views/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/views/dialog.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/views/login.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/views/permission.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/views/pickers.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/views/prompt.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/views/status.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/views/transcript.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/conftest.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/conftest.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/fixtures/echo_server.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/report.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/test_chat.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/test_cli.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/test_extend.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/test_input.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/test_print.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/test_live.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/test_live_codex.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/test_live_devin.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/test_live_tavily.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.12}/uv.lock +0 -0
|
@@ -28,6 +28,42 @@ the project follows [semantic versioning](https://semver.org/).
|
|
|
28
28
|
|
|
29
29
|
---
|
|
30
30
|
|
|
31
|
+
## [0.2.12] - 2026-10-01
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- `/exit` and `ctrl+d` return the shell at once. They used to wait up to 10
|
|
36
|
+
seconds on a model call renaming the session; sessions are no longer renamed
|
|
37
|
+
on the way out, in the TUI or with `hx -p`.
|
|
38
|
+
- A name set with `/title` is no longer replaced by a generated one, including
|
|
39
|
+
one that was still being generated when you set it.
|
|
40
|
+
- The session name is generated from the opening and the latest stretch of the
|
|
41
|
+
conversation, not only its first 2000 characters.
|
|
42
|
+
- A session left before its first name came back is named from your first
|
|
43
|
+
message, instead of being listed by `/resume` with no name.
|
|
44
|
+
- A session left with `/resume` into another directory is renamed for what it
|
|
45
|
+
became, by the HX that starts there, in the background.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## [0.2.11] - 2026-10-01
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- `esc` while a tool runs no longer breaks the session: the next question is
|
|
54
|
+
answered instead of every later turn failing. A session that was interrupted
|
|
55
|
+
or killed mid-tool before this release also carries on after `hx resume`.
|
|
56
|
+
- `esc` during a `Bash` command stops the command. The next `Bash` call gets its
|
|
57
|
+
own output and exit code, not what was left of the one stopped.
|
|
58
|
+
- An interrupted call keeps what it printed, on screen and for the model, and
|
|
59
|
+
reads "Interrupted after <time>" instead of looking like a failure.
|
|
60
|
+
- A tool that finished keeps its result when `esc` lands while its
|
|
61
|
+
`PostToolUse` hook runs.
|
|
62
|
+
- `Bash` shows a short line such as "Starting..." as soon as it is printed,
|
|
63
|
+
rather than only when more output follows.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
31
67
|
## [0.2.10] - 2026-09-29
|
|
32
68
|
|
|
33
69
|
### Fixed
|
|
@@ -758,7 +794,9 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
|
|
|
758
794
|
- `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
|
|
759
795
|
self-update, and `install.sh` bootstrapping uv with a pinned Python.
|
|
760
796
|
|
|
761
|
-
[Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.
|
|
797
|
+
[Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.12...HEAD
|
|
798
|
+
[0.2.12]: https://github.com/aletisunil/hx/compare/v0.2.11...v0.2.12
|
|
799
|
+
[0.2.11]: https://github.com/aletisunil/hx/compare/v0.2.10...v0.2.11
|
|
762
800
|
[0.2.10]: https://github.com/aletisunil/hx/compare/v0.2.9...v0.2.10
|
|
763
801
|
[0.2.9]: https://github.com/aletisunil/hx/compare/v0.2.8...v0.2.9
|
|
764
802
|
[0.2.8]: https://github.com/aletisunil/hx/compare/v0.2.7...v0.2.8
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hx-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.12
|
|
4
4
|
Summary: HX - an agent harness for the terminal
|
|
5
5
|
Project-URL: Homepage, https://hx.sunilaleti.dev
|
|
6
6
|
Project-URL: Documentation, https://hx.sunilaleti.dev
|
|
@@ -709,8 +709,14 @@ After the first exchange, HX asks the model for a short name for the session and
|
|
|
709
709
|
writes it to `~/.hx/sessions/<id>/meta.json`, so `/resume` lists work rather
|
|
710
710
|
than timestamps. It is one small call — cap 32 output tokens, `models.title_model`
|
|
711
711
|
if you want a cheaper model for it — and it is counted in `/cost` like any other.
|
|
712
|
-
If the call fails the session is still named,
|
|
713
|
-
|
|
712
|
+
If the call fails, or you leave before it is back, the session is still named,
|
|
713
|
+
from your first message.
|
|
714
|
+
|
|
715
|
+
The live session is never renamed while it runs, and nothing is renamed on
|
|
716
|
+
`/exit`, which returns your shell at once. A session you leave with `/clear` or
|
|
717
|
+
`/resume` - including a `/resume` into another directory - is renamed in the
|
|
718
|
+
background for what it became. `/title <text>` renames it yourself, and a name
|
|
719
|
+
you chose is never replaced.
|
|
714
720
|
|
|
715
721
|
`/resume` lists every session except the one you are in: this directory's
|
|
716
722
|
first, then other directories', each row naming where it lives. A session from
|
|
@@ -669,8 +669,14 @@ After the first exchange, HX asks the model for a short name for the session and
|
|
|
669
669
|
writes it to `~/.hx/sessions/<id>/meta.json`, so `/resume` lists work rather
|
|
670
670
|
than timestamps. It is one small call — cap 32 output tokens, `models.title_model`
|
|
671
671
|
if you want a cheaper model for it — and it is counted in `/cost` like any other.
|
|
672
|
-
If the call fails the session is still named,
|
|
673
|
-
|
|
672
|
+
If the call fails, or you leave before it is back, the session is still named,
|
|
673
|
+
from your first message.
|
|
674
|
+
|
|
675
|
+
The live session is never renamed while it runs, and nothing is renamed on
|
|
676
|
+
`/exit`, which returns your shell at once. A session you leave with `/clear` or
|
|
677
|
+
`/resume` - including a `/resume` into another directory - is renamed in the
|
|
678
|
+
background for what it became. `/title <text>` renames it yourself, and a name
|
|
679
|
+
you chose is never replaced.
|
|
674
680
|
|
|
675
681
|
`/resume` lists every session except the one you are in: this directory's
|
|
676
682
|
first, then other directories', each row naming where it lives. A session from
|
|
@@ -609,6 +609,8 @@ def run_tui_command(parsed: ParsedArgs) -> int:
|
|
|
609
609
|
from hx.auth.resolve import MissingCredential
|
|
610
610
|
from hx.tui.app import run_tui
|
|
611
611
|
|
|
612
|
+
# Taken before anything starts, so no tool, MCP server or nested hx inherits it.
|
|
613
|
+
rename_left = os.environ.pop(RENAME_LEFT_ENV, None)
|
|
612
614
|
try:
|
|
613
615
|
resume = _resume_target(parsed)
|
|
614
616
|
moved = _adopt_session_directory(parsed, resume)
|
|
@@ -648,10 +650,8 @@ def run_tui_command(parsed: ParsedArgs) -> int:
|
|
|
648
650
|
notices=runtime.notices,
|
|
649
651
|
checkpoints=runtime.checkpoints,
|
|
650
652
|
tracker=runtime.tracker,
|
|
653
|
+
rename_left=rename_left,
|
|
651
654
|
)
|
|
652
|
-
# The TUI is down but the provider is not, which is the one moment
|
|
653
|
-
# the whole session exists and nothing is competing for the screen.
|
|
654
|
-
await _rename_closed_session(runtime.loop)
|
|
655
655
|
finally:
|
|
656
656
|
runtime.bus.close()
|
|
657
657
|
# Everything, not only the provider: a relaunch replaces this
|
|
@@ -661,7 +661,8 @@ def run_tui_command(parsed: ParsedArgs) -> int:
|
|
|
661
661
|
|
|
662
662
|
asyncio.run(main_async())
|
|
663
663
|
if relaunch is not None:
|
|
664
|
-
|
|
664
|
+
left = runtime.loop.session
|
|
665
|
+
_relaunch(relaunch, left=left.meta.session_id if left.messages else None)
|
|
665
666
|
return 0
|
|
666
667
|
|
|
667
668
|
|
|
@@ -724,9 +725,19 @@ def relaunch_argv(argv: list[str], session_id: str) -> list[str]:
|
|
|
724
725
|
return [*kept, "resume", session_id]
|
|
725
726
|
|
|
726
727
|
|
|
727
|
-
|
|
728
|
+
RENAME_LEFT_ENV = "HX_RENAME_LEFT_SESSION"
|
|
729
|
+
"""The session a relaunch left behind, handed to the HX that replaces it.
|
|
730
|
+
|
|
731
|
+
``/clear`` and ``/resume`` rename the session the user leaves, in the
|
|
732
|
+
background. A relaunch cannot: ``exec`` ends this process before the call is
|
|
733
|
+
back. So the new process does it, as detached as it is everywhere else."""
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _relaunch(session_id: str, *, left: str | None = None) -> None:
|
|
728
737
|
"""Replace this process with HX resumed on ``session_id``.
|
|
729
738
|
|
|
739
|
+
``left`` is the session being left, for the new process to rename.
|
|
740
|
+
|
|
730
741
|
``exec`` rather than a child: the terminal, the pid and the shell's job
|
|
731
742
|
control stay exactly as they were, and there is no parent left waiting.
|
|
732
743
|
|
|
@@ -738,30 +749,13 @@ def _relaunch(session_id: str) -> None:
|
|
|
738
749
|
"""
|
|
739
750
|
prefix = sys.orig_argv[: len(sys.orig_argv) - len(sys.argv) + 1]
|
|
740
751
|
command = [sys.executable, *prefix[1:], *relaunch_argv(sys.argv[1:], session_id)]
|
|
752
|
+
if left is not None:
|
|
753
|
+
os.environ[RENAME_LEFT_ENV] = left
|
|
741
754
|
sys.stdout.flush()
|
|
742
755
|
sys.stderr.flush()
|
|
743
756
|
os.execv(sys.executable, command)
|
|
744
757
|
|
|
745
758
|
|
|
746
|
-
RENAME_TIMEOUT_SECONDS = 10.0
|
|
747
|
-
"""A session name is not worth making the user wait for. Exit wins the tie."""
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
async def _rename_closed_session(loop: Any) -> None:
|
|
751
|
-
"""Re-name the session now that it is over, if the work moved on.
|
|
752
|
-
|
|
753
|
-
The first name is written after one exchange and never revisited, so
|
|
754
|
-
``/resume`` ends up listing opening questions. Bounded and swallowed: this
|
|
755
|
-
runs while the user is waiting for their shell prompt back.
|
|
756
|
-
"""
|
|
757
|
-
try:
|
|
758
|
-
await asyncio.wait_for(loop.retitle_session(), timeout=RENAME_TIMEOUT_SECONDS)
|
|
759
|
-
except Exception:
|
|
760
|
-
# Including the timeout. A rename that cannot happen is not an error the
|
|
761
|
-
# user needs at the moment they are leaving; the old name still stands.
|
|
762
|
-
return
|
|
763
|
-
|
|
764
|
-
|
|
765
759
|
def run_print_command(parsed: ParsedArgs) -> int:
|
|
766
760
|
"""Headless single-prompt run. Streams assistant text to stdout and tool
|
|
767
761
|
activity to stderr, so stdout stays pipeable."""
|
|
@@ -828,7 +822,6 @@ def run_print_command(parsed: ParsedArgs) -> int:
|
|
|
828
822
|
print(f"[mcp] {status.name} unavailable: {status.error}", file=sys.stderr)
|
|
829
823
|
try:
|
|
830
824
|
result = await runtime.loop.run(parsed.prompt, images)
|
|
831
|
-
await _rename_closed_session(runtime.loop)
|
|
832
825
|
finally:
|
|
833
826
|
await asyncio.sleep(0.05)
|
|
834
827
|
runtime.bus.close()
|
|
@@ -67,6 +67,9 @@ class ToolCallFinished(Event):
|
|
|
67
67
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
68
68
|
"""Structured detail the renderer draws with - an edit's diff, a command's
|
|
69
69
|
exit code. Presentation only: the model never sees it."""
|
|
70
|
+
interrupted: bool = False
|
|
71
|
+
"""The user stopped the turn while this call ran. Whatever it streamed is
|
|
72
|
+
kept: how far a command got is what the user wants to see."""
|
|
70
73
|
|
|
71
74
|
|
|
72
75
|
@dataclass(slots=True)
|
|
@@ -42,7 +42,9 @@ from hx.core.messages import (
|
|
|
42
42
|
ToolResultBlock,
|
|
43
43
|
ToolUseBlock,
|
|
44
44
|
UserTurn,
|
|
45
|
+
answer_unanswered_calls,
|
|
45
46
|
assistant_message,
|
|
47
|
+
interrupted_result,
|
|
46
48
|
tool_result_message,
|
|
47
49
|
user_message,
|
|
48
50
|
)
|
|
@@ -50,7 +52,7 @@ from hx.core.session import Environment
|
|
|
50
52
|
from hx.core.usage import TurnUsage, compute_cost
|
|
51
53
|
from hx.hooks.spec import HookOutcome
|
|
52
54
|
from hx.providers.base import ProviderError, ProviderRequest, StreamDelta, StreamEnd
|
|
53
|
-
from hx.tools.base import ToolContext
|
|
55
|
+
from hx.tools.base import ToolContext, ToolResult
|
|
54
56
|
|
|
55
57
|
if TYPE_CHECKING:
|
|
56
58
|
from collections.abc import Awaitable, Callable, Sequence
|
|
@@ -225,6 +227,27 @@ class AgentLoop:
|
|
|
225
227
|
self.bus.publish(TurnFinished(self._turn_index, stop_reason))
|
|
226
228
|
|
|
227
229
|
calls = message.tool_uses()
|
|
230
|
+
if calls and self._cancelled:
|
|
231
|
+
# Stopped between the answer and its tools: none of them ran,
|
|
232
|
+
# and the transcript has to say so before the turn ends - as
|
|
233
|
+
# does the screen, which a resumed session draws them on too.
|
|
234
|
+
for call in calls:
|
|
235
|
+
self.bus.publish(
|
|
236
|
+
ToolCallStarted(
|
|
237
|
+
tool_use_id=call.id,
|
|
238
|
+
name=self._display_name(call.name),
|
|
239
|
+
input=call.input,
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
self.bus.publish(
|
|
243
|
+
ToolCallFinished(
|
|
244
|
+
tool_use_id=call.id,
|
|
245
|
+
is_error=True,
|
|
246
|
+
summary="interrupted",
|
|
247
|
+
interrupted=True,
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
produced.append(self._answer_calls(calls, {}))
|
|
228
251
|
if not calls or self._cancelled:
|
|
229
252
|
# A steer that arrived during the last stretch is the next thing
|
|
230
253
|
# the user said, so the conversation continues rather than
|
|
@@ -234,10 +257,15 @@ class AgentLoop:
|
|
|
234
257
|
await self._name_session()
|
|
235
258
|
return TurnResult(stop_reason, produced)
|
|
236
259
|
|
|
237
|
-
results =
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
260
|
+
results: dict[str, ToolResultBlock] = {}
|
|
261
|
+
try:
|
|
262
|
+
await self._execute_tools(calls, results)
|
|
263
|
+
finally:
|
|
264
|
+
# Also on interrupt. The calls are already in the transcript,
|
|
265
|
+
# and a call left without a result is rejected by every route on
|
|
266
|
+
# the next request - so every later turn in the session would
|
|
267
|
+
# fail the same way. What finished keeps its real result.
|
|
268
|
+
produced.append(self._answer_calls(calls, results))
|
|
241
269
|
|
|
242
270
|
self.bus.publish(
|
|
243
271
|
ErrorRaised(message=f"Stopped after {self.MAX_TURNS} turns", recoverable=True)
|
|
@@ -324,7 +352,8 @@ class AgentLoop:
|
|
|
324
352
|
moment ``run`` returns - a background task would simply be killed.
|
|
325
353
|
|
|
326
354
|
Naming must never cost the user a turn, so every failure falls back to
|
|
327
|
-
the first thing they said
|
|
355
|
+
the first thing they said - including leaving before the call is back,
|
|
356
|
+
which would otherwise list the session in ``/resume`` with no name.
|
|
328
357
|
"""
|
|
329
358
|
if self.origin is not None or self.session.meta.title or self._cancelled:
|
|
330
359
|
return
|
|
@@ -333,37 +362,50 @@ class AgentLoop:
|
|
|
333
362
|
if messages is None:
|
|
334
363
|
return
|
|
335
364
|
|
|
336
|
-
title = await self._ask_for_title(messages, self.session)
|
|
337
365
|
from hx.core.title import fallback_title
|
|
338
366
|
|
|
367
|
+
try:
|
|
368
|
+
title = await self._ask_for_title(messages, self.session)
|
|
369
|
+
except asyncio.CancelledError:
|
|
370
|
+
if not self.session.meta.title:
|
|
371
|
+
self.session.set_title(fallback_title(messages))
|
|
372
|
+
raise
|
|
373
|
+
# The user may have named it with /title while the call was out; the
|
|
374
|
+
# turn is over by then, and their name stands.
|
|
375
|
+
if self.session.meta.title:
|
|
376
|
+
return
|
|
377
|
+
|
|
339
378
|
self.session.set_title(title or fallback_title(messages))
|
|
340
379
|
|
|
341
|
-
async def retitle_session(self,
|
|
342
|
-
"""Rename a session for what it turned into, as it
|
|
380
|
+
async def retitle_session(self, target: Session) -> None:
|
|
381
|
+
"""Rename a session for what it turned into, as the user leaves it.
|
|
343
382
|
|
|
344
383
|
The first name is written after one exchange, so it describes an opening
|
|
345
|
-
question.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
title alone rather than replacing something specific with a guess.
|
|
384
|
+
question. ``/clear`` and ``/resume`` both leave a session behind, and
|
|
385
|
+
that is the moment to name it for what it became - detached, so nobody
|
|
386
|
+
waits on it. The live session is never renamed while it runs.
|
|
387
|
+
|
|
388
|
+
Only when the conversation changed since the name was written, never
|
|
389
|
+
over a name the user chose, and never at the cost of the name already
|
|
390
|
+
there: a rename that fails leaves the old title alone rather than
|
|
391
|
+
replacing something specific with a guess.
|
|
354
392
|
"""
|
|
355
393
|
if self.origin is not None:
|
|
356
394
|
return
|
|
357
395
|
|
|
358
|
-
|
|
396
|
+
if target.meta.title_pinned:
|
|
397
|
+
return
|
|
359
398
|
messages = self._nameable_messages(target)
|
|
360
399
|
if messages is None:
|
|
361
400
|
return
|
|
362
|
-
|
|
401
|
+
prompt_count = target.meta.prompt_count
|
|
402
|
+
if prompt_count == target.meta.title_prompt_count:
|
|
363
403
|
return
|
|
364
404
|
|
|
365
|
-
|
|
366
|
-
|
|
405
|
+
title = await self._ask_for_title(messages, target)
|
|
406
|
+
# The user may have named it while the call was out; their name stands.
|
|
407
|
+
if title and not target.meta.title_pinned:
|
|
408
|
+
target.set_title(title, prompt_count=prompt_count)
|
|
367
409
|
|
|
368
410
|
def _nameable_messages(self, session: Session) -> list[Message] | None:
|
|
369
411
|
"""The transcript a name is derived from, or ``None`` if there is none."""
|
|
@@ -459,7 +501,9 @@ class AgentLoop:
|
|
|
459
501
|
)
|
|
460
502
|
|
|
461
503
|
async def _build_request(self) -> ProviderRequest:
|
|
462
|
-
messages =
|
|
504
|
+
messages = answer_unanswered_calls(
|
|
505
|
+
await self.injections.apply(self.session.active_messages())
|
|
506
|
+
)
|
|
463
507
|
if self.model_info is None or not self.model_info.supports_images:
|
|
464
508
|
from hx.core.images import without_images
|
|
465
509
|
|
|
@@ -570,31 +614,48 @@ class AgentLoop:
|
|
|
570
614
|
)
|
|
571
615
|
)
|
|
572
616
|
|
|
573
|
-
async def _execute_tools(
|
|
617
|
+
async def _execute_tools(
|
|
618
|
+
self, calls: list[ToolUseBlock], results: dict[str, ToolResultBlock]
|
|
619
|
+
) -> None:
|
|
574
620
|
"""Permission-check, then run. Read-only calls are gathered concurrently;
|
|
575
621
|
mutating calls run in emission order.
|
|
576
622
|
|
|
623
|
+
Each result lands in ``results`` the moment its call finishes, so an
|
|
624
|
+
interrupt keeps what had already run.
|
|
625
|
+
|
|
577
626
|
A denied call becomes an ``is_error`` result rather than an exception, so
|
|
578
627
|
the model can react instead of the turn dying.
|
|
579
628
|
"""
|
|
580
|
-
results: dict[str, ToolResultBlock] = {}
|
|
581
629
|
concurrent: list[ToolUseBlock] = []
|
|
582
630
|
|
|
631
|
+
async def run(call: ToolUseBlock) -> None:
|
|
632
|
+
results[call.id] = await self._run_one(call, results)
|
|
633
|
+
|
|
583
634
|
for call in calls:
|
|
584
635
|
if self._runs_serially(call):
|
|
585
636
|
for pending in concurrent:
|
|
586
|
-
|
|
637
|
+
await run(pending)
|
|
587
638
|
concurrent.clear()
|
|
588
|
-
|
|
639
|
+
await run(call)
|
|
589
640
|
else:
|
|
590
641
|
concurrent.append(call)
|
|
591
642
|
|
|
592
643
|
if concurrent:
|
|
593
|
-
|
|
594
|
-
for call, result in zip(concurrent, gathered, strict=True):
|
|
595
|
-
results[call.id] = result
|
|
644
|
+
await asyncio.gather(*(run(c) for c in concurrent))
|
|
596
645
|
|
|
597
|
-
|
|
646
|
+
def _answer_calls(
|
|
647
|
+
self, calls: list[ToolUseBlock], results: dict[str, ToolResultBlock]
|
|
648
|
+
) -> Message:
|
|
649
|
+
"""Append the results for ``calls``, in the order the model asked for them.
|
|
650
|
+
|
|
651
|
+
A call with no result was cut off by the user, before it started or
|
|
652
|
+
while it ran, and is answered as exactly that.
|
|
653
|
+
"""
|
|
654
|
+
message = tool_result_message(
|
|
655
|
+
[results.get(call.id) or interrupted_result(call.id) for call in calls]
|
|
656
|
+
)
|
|
657
|
+
self.session.append(message)
|
|
658
|
+
return message
|
|
598
659
|
|
|
599
660
|
def _runs_serially(self, call: ToolUseBlock) -> bool:
|
|
600
661
|
try:
|
|
@@ -609,7 +670,10 @@ class AgentLoop:
|
|
|
609
670
|
"""Attribute a subagent's tool calls so they do not read as the parent's."""
|
|
610
671
|
return name if self.origin is None else f"{self.origin} > {name}"
|
|
611
672
|
|
|
612
|
-
async def _run_one(
|
|
673
|
+
async def _run_one(
|
|
674
|
+
self, call: ToolUseBlock, results: dict[str, ToolResultBlock]
|
|
675
|
+
) -> ToolResultBlock:
|
|
676
|
+
"""Run one call. Interrupted, it leaves its answer in ``results``."""
|
|
613
677
|
started = time.monotonic()
|
|
614
678
|
|
|
615
679
|
# PreToolUse runs before the call is announced. A hook that rewrites the
|
|
@@ -626,7 +690,40 @@ class AgentLoop:
|
|
|
626
690
|
tool_use_id=call.id, name=self._display_name(call.name), input=call.input
|
|
627
691
|
)
|
|
628
692
|
)
|
|
693
|
+
streamed: list[str] = []
|
|
694
|
+
try:
|
|
695
|
+
return await self._run_announced(call, pre, started, streamed, results)
|
|
696
|
+
except asyncio.CancelledError:
|
|
697
|
+
if call.id in results:
|
|
698
|
+
# The tool had finished and only its hooks were cut off.
|
|
699
|
+
raise
|
|
700
|
+
cap = self.settings.context.tool_output_char_cap
|
|
701
|
+
results[call.id] = interrupted_result(call.id, "".join(streamed)[-cap:])
|
|
702
|
+
# The call is on screen as running; left alone it would spin on
|
|
703
|
+
# under the "Interrupted" notice for the rest of the session.
|
|
704
|
+
self.bus.publish(
|
|
705
|
+
ToolCallFinished(
|
|
706
|
+
tool_use_id=call.id,
|
|
707
|
+
is_error=True,
|
|
708
|
+
duration_ms=(time.monotonic() - started) * 1000,
|
|
709
|
+
summary="interrupted",
|
|
710
|
+
interrupted=True,
|
|
711
|
+
)
|
|
712
|
+
)
|
|
713
|
+
raise
|
|
629
714
|
|
|
715
|
+
async def _run_announced(
|
|
716
|
+
self,
|
|
717
|
+
call: ToolUseBlock,
|
|
718
|
+
pre: HookOutcome,
|
|
719
|
+
started: float,
|
|
720
|
+
streamed: list[str],
|
|
721
|
+
results: dict[str, ToolResultBlock],
|
|
722
|
+
) -> ToolResultBlock:
|
|
723
|
+
"""The part of :meth:`_run_one` after the call is on screen.
|
|
724
|
+
|
|
725
|
+
What the tool prints is kept in ``streamed`` for an interrupt to report.
|
|
726
|
+
"""
|
|
630
727
|
if pre.blocked:
|
|
631
728
|
refusal = ToolResultBlock(
|
|
632
729
|
tool_use_id=call.id,
|
|
@@ -657,18 +754,34 @@ class AgentLoop:
|
|
|
657
754
|
)
|
|
658
755
|
return denied
|
|
659
756
|
|
|
757
|
+
def progress(chunk: str) -> None:
|
|
758
|
+
streamed.append(chunk)
|
|
759
|
+
self._emit_progress(call.id, chunk)
|
|
760
|
+
|
|
660
761
|
ctx = ToolContext(
|
|
661
762
|
cwd=self.settings.cwd,
|
|
662
763
|
session_id=self.session.meta.session_id,
|
|
663
764
|
tool_use_id=call.id,
|
|
664
765
|
settings=self.settings,
|
|
665
|
-
emit_progress=
|
|
766
|
+
emit_progress=progress,
|
|
666
767
|
)
|
|
667
768
|
result = await self.tools.call(call.name, call.input, ctx)
|
|
668
769
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
770
|
+
try:
|
|
771
|
+
post = await self._fire_hooks(
|
|
772
|
+
lambda h: h.post_tool_use(call.name, call.input, result.content, result.is_error)
|
|
773
|
+
)
|
|
774
|
+
except asyncio.CancelledError:
|
|
775
|
+
# The tool finished and its effects are real: its own result stands,
|
|
776
|
+
# rather than telling the model a write that happened never did.
|
|
777
|
+
results[call.id] = self._finish(call, result, HookOutcome(), started)
|
|
778
|
+
raise
|
|
779
|
+
return self._finish(call, result, post, started)
|
|
780
|
+
|
|
781
|
+
def _finish(
|
|
782
|
+
self, call: ToolUseBlock, result: ToolResult, post: HookOutcome, started: float
|
|
783
|
+
) -> ToolResultBlock:
|
|
784
|
+
"""Announce a call the tool finished, and its result for the model."""
|
|
672
785
|
content = result.content
|
|
673
786
|
if post.blocked:
|
|
674
787
|
content = f"{content}\n\nA hook rejected this result: {post.reason}"
|
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import time
|
|
11
11
|
from collections.abc import Sequence
|
|
12
|
-
from dataclasses import dataclass, field
|
|
12
|
+
from dataclasses import dataclass, field, replace
|
|
13
13
|
from enum import StrEnum
|
|
14
14
|
from typing import Any, Literal
|
|
15
15
|
|
|
@@ -238,3 +238,60 @@ def assistant_message(blocks: list[ContentBlock], model: str | None = None) -> M
|
|
|
238
238
|
def tool_result_message(results: list[ToolResultBlock]) -> Message:
|
|
239
239
|
"""Tool results are carried on a ``user``-role message, matching the provider wire format."""
|
|
240
240
|
return Message(role="user", content=list(results))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
INTERRUPTED = "Interrupted by the user before this call finished."
|
|
244
|
+
_OUTPUT_SO_FAR = "\n\nOutput before the interrupt:\n"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def interrupted_result(tool_use_id: str, output: str = "") -> ToolResultBlock:
|
|
248
|
+
"""The answer for a call the user cut off, before it started or while it ran.
|
|
249
|
+
|
|
250
|
+
What it printed before then goes with it: how far a command got is what
|
|
251
|
+
the model needs to decide whether to run it again.
|
|
252
|
+
"""
|
|
253
|
+
content = f"{INTERRUPTED}{_OUTPUT_SO_FAR}{output}" if output.strip() else INTERRUPTED
|
|
254
|
+
return ToolResultBlock(tool_use_id=tool_use_id, content=content, is_error=True)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def interrupted_output(result: ToolResultBlock) -> str | None:
|
|
258
|
+
"""What an interrupted call printed, or ``None`` when ``result`` is not one."""
|
|
259
|
+
if not (result.is_error and result.content.startswith(INTERRUPTED)):
|
|
260
|
+
return None
|
|
261
|
+
return result.content.removeprefix(INTERRUPTED).removeprefix(_OUTPUT_SO_FAR)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def answer_unanswered_calls(messages: Sequence[Message]) -> list[Message]:
|
|
265
|
+
"""``messages`` with every tool call answered by the message after it.
|
|
266
|
+
|
|
267
|
+
The loop answers every call it starts, interrupted or not, so a request is
|
|
268
|
+
never built while a call waits. A transcript can still hold a call with no
|
|
269
|
+
result: written before the loop did that, or by a process killed mid-tool.
|
|
270
|
+
Every route rejects such a call - on every request after it, since it never
|
|
271
|
+
leaves the history - so the missing results are filled in as interrupted.
|
|
272
|
+
Only the request is repaired; the transcript on disk keeps what happened.
|
|
273
|
+
"""
|
|
274
|
+
repaired: list[Message] = []
|
|
275
|
+
waiting: list[ToolUseBlock] = []
|
|
276
|
+
for message in [*messages, None]:
|
|
277
|
+
if waiting:
|
|
278
|
+
results = {r.tool_use_id: r for r in message.tool_results()} if message else {}
|
|
279
|
+
if any(call.id not in results for call in waiting):
|
|
280
|
+
answers = [results.get(c.id) or interrupted_result(c.id) for c in waiting]
|
|
281
|
+
ids = {call.id for call in waiting}
|
|
282
|
+
if message is not None and results:
|
|
283
|
+
# Into the results already there: the calls stay answered
|
|
284
|
+
# by one message, in the order they were made.
|
|
285
|
+
rest = [
|
|
286
|
+
b
|
|
287
|
+
for b in message.content
|
|
288
|
+
if not (isinstance(b, ToolResultBlock) and b.tool_use_id in ids)
|
|
289
|
+
]
|
|
290
|
+
message = replace(message, content=[*answers, *rest])
|
|
291
|
+
else:
|
|
292
|
+
repaired.append(tool_result_message(answers))
|
|
293
|
+
if message is None:
|
|
294
|
+
break
|
|
295
|
+
repaired.append(message)
|
|
296
|
+
waiting = message.tool_uses() if message.role == "assistant" else []
|
|
297
|
+
return repaired
|
|
@@ -39,9 +39,12 @@ class SessionMeta:
|
|
|
39
39
|
updated_at: float
|
|
40
40
|
model: str
|
|
41
41
|
title: str | None = None
|
|
42
|
-
|
|
43
|
-
"""How
|
|
44
|
-
session can tell
|
|
42
|
+
title_prompt_count: int = 0
|
|
43
|
+
"""How many prompts the session held when the title was written, so the
|
|
44
|
+
session can tell when the conversation has moved on from its name."""
|
|
45
|
+
title_pinned: bool = False
|
|
46
|
+
"""The user named the session with ``/title``. Their name is never replaced
|
|
47
|
+
by a generated one."""
|
|
45
48
|
message_count: int = 0
|
|
46
49
|
"""Every message record: the user's prompts, the assistant's replies, and
|
|
47
50
|
the tool-result messages that ride the user role to match the provider wire
|
|
@@ -166,14 +169,24 @@ class Session:
|
|
|
166
169
|
self._pending.append({"kind": "environment", "data": asdict(environment)})
|
|
167
170
|
self.flush()
|
|
168
171
|
|
|
169
|
-
def set_title(
|
|
172
|
+
def set_title(
|
|
173
|
+
self, title: str, *, pinned: bool = False, prompt_count: int | None = None
|
|
174
|
+
) -> None:
|
|
170
175
|
"""Name the session for ``/resume``.
|
|
171
176
|
|
|
177
|
+
``pinned`` marks a name the user chose, which generated names never
|
|
178
|
+
replace. ``prompt_count`` is how much of the conversation the name
|
|
179
|
+
describes, when that is less than the whole of it - a name generated in
|
|
180
|
+
the background describes the transcript as it was when it was asked for.
|
|
181
|
+
|
|
172
182
|
``updated_at`` is deliberately left alone: it orders the picker and must
|
|
173
183
|
keep reflecting real activity, not the moment a name was written.
|
|
174
184
|
"""
|
|
175
185
|
self.meta.title = title.strip() or None
|
|
176
|
-
self.meta.
|
|
186
|
+
self.meta.title_pinned = pinned and self.meta.title is not None
|
|
187
|
+
self.meta.title_prompt_count = (
|
|
188
|
+
self.meta.prompt_count if prompt_count is None else prompt_count
|
|
189
|
+
)
|
|
177
190
|
self._write_meta()
|
|
178
191
|
|
|
179
192
|
def record_usage(self, usage: TurnUsage) -> None:
|
|
@@ -27,6 +27,10 @@ TITLE_MAX_TOKENS = 32
|
|
|
27
27
|
MAX_TITLE_CHARS = 50
|
|
28
28
|
"""A session row is read at a glance, in a list. Longer names stop being names."""
|
|
29
29
|
TRANSCRIPT_CHARS = 2_000
|
|
30
|
+
"""How much of the conversation the naming call sees."""
|
|
31
|
+
OPENING_CHARS = 500
|
|
32
|
+
"""Of that, how much is the opening. The rest is the latest stretch: a session
|
|
33
|
+
renamed for what it turned into has to be shown what it turned into."""
|
|
30
34
|
|
|
31
35
|
TITLE_PROMPT = """\
|
|
32
36
|
Name this coding session so it can be recognised in a list of sessions.
|
|
@@ -49,7 +53,7 @@ def build_title_request(messages: list[Message], model: str) -> ProviderRequest:
|
|
|
49
53
|
from hx.core.context import AssembledContext, PromptSection
|
|
50
54
|
from hx.providers.base import ProviderRequest
|
|
51
55
|
|
|
52
|
-
transcript = render_transcript(messages)
|
|
56
|
+
transcript = _excerpt(render_transcript(messages))
|
|
53
57
|
context = AssembledContext(
|
|
54
58
|
system=[PromptSection("system", "You name things briefly and precisely.")],
|
|
55
59
|
messages=[
|
|
@@ -83,6 +87,15 @@ async def generate_title(
|
|
|
83
87
|
return clean_title("".join(parts)) or None, usage
|
|
84
88
|
|
|
85
89
|
|
|
90
|
+
def _excerpt(transcript: str) -> str:
|
|
91
|
+
"""The opening and the latest stretch of a transcript too long to send whole."""
|
|
92
|
+
if len(transcript) <= TRANSCRIPT_CHARS:
|
|
93
|
+
return transcript
|
|
94
|
+
opening = transcript[:OPENING_CHARS]
|
|
95
|
+
latest = transcript[-(TRANSCRIPT_CHARS - OPENING_CHARS) :]
|
|
96
|
+
return f"{opening}\n[... earlier conversation omitted ...]\n{latest}"
|
|
97
|
+
|
|
98
|
+
|
|
86
99
|
def clean_title(raw: str) -> str:
|
|
87
100
|
"""Strip the decoration models add to a name, and cap its length.
|
|
88
101
|
|