thwip-cli 1.1.3__tar.gz → 1.2.0__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.
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/.github/workflows/publish.yml +4 -2
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/PKG-INFO +37 -2
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/README.md +36 -1
- thwip_cli-1.2.0/docs/handoff-research.md +50 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/pyproject.toml +1 -1
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/tests/test_cli.py +1 -0
- thwip_cli-1.2.0/tests/test_handoff.py +222 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/__init__.py +1 -1
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/agents/base.py +4 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/agents/ollama_agent.py +11 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/cli.py +59 -3
- thwip_cli-1.2.0/thwip/handoff.py +91 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/session.py +20 -2
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/shortcuts.py +3 -2
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/theme.py +1 -1
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/uv.lock +1 -1
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/.gitignore +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/LICENSE +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/install.sh +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/tests/test_agents.py +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/tests/test_config.py +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/tests/test_detector.py +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/tests/test_session.py +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/tests/test_tools.py +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/__main__.py +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/agents/__init__.py +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/agents/claude_agent.py +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/agents/deepseek_agent.py +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/agents/google_agent.py +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/agents/groq_agent.py +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/agents/openai_agent.py +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/agents/openrouter_agent.py +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/config.py +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/detector.py +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/limits.py +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/tools/__init__.py +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/tools/code_runner.py +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/tools/file_editor.py +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/tools/git_ops.py +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/tools/terminal.py +0 -0
- {thwip_cli-1.1.3 → thwip_cli-1.2.0}/thwip/utils.py +0 -0
|
@@ -49,7 +49,7 @@ jobs:
|
|
|
49
49
|
run: uv build
|
|
50
50
|
|
|
51
51
|
- name: Verify package metadata
|
|
52
|
-
run: uvx twine check dist
|
|
52
|
+
run: uvx twine check dist/*.whl dist/*.tar.gz
|
|
53
53
|
|
|
54
54
|
- name: Publish to PyPI
|
|
55
55
|
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -60,5 +60,7 @@ jobs:
|
|
|
60
60
|
uses: softprops/action-gh-release@v2
|
|
61
61
|
if: startsWith(github.ref, 'refs/tags/')
|
|
62
62
|
with:
|
|
63
|
-
files:
|
|
63
|
+
files: |
|
|
64
|
+
dist/*.whl
|
|
65
|
+
dist/*.tar.gz
|
|
64
66
|
generate_release_notes: true
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: thwip-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Universal coding agent multiplexer: detect, switch, and route between AI coding agents seamlessly
|
|
5
5
|
Project-URL: Homepage, https://github.com/tanmayhutt/thwip-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/tanmayhutt/thwip-cli
|
|
@@ -46,7 +46,8 @@ Description-Content-Type: text/markdown
|
|
|
46
46
|
## Features
|
|
47
47
|
|
|
48
48
|
- **Auto-Detection**: Discovers installed AI coding agents (Claude Code, Antigravity, Gemini CLI, OpenAI/Codex, Aider, Copilot, Cursor, Windsurf, Cline, Ollama) and configured credentials.
|
|
49
|
-
- **Context Portability**: Switch
|
|
49
|
+
- **Context Portability**: Switch providers with stored conversational text. Working files remain in the selected local project; they are not automatically uploaded.
|
|
50
|
+
- **Handoff Preview**: Inspect text continuity, omitted state, capability changes, approximate context pressure, and a text fingerprint before switching. Runs locally without model calls.
|
|
50
51
|
- **Dynamic UI**: Terminal interface adapts its status bar, capabilities, and theme based on the active provider.
|
|
51
52
|
- **Capability Disclaimers**: Highlights when an agent lacks specific capabilities such as file editing or code execution.
|
|
52
53
|
- **Rate Limit Failover**: Detects HTTP 429 errors or quota exhaustion and prompts instant switching to ready fallback models.
|
|
@@ -89,6 +90,7 @@ thwip
|
|
|
89
90
|
| Command | Action |
|
|
90
91
|
|:---|:---|
|
|
91
92
|
| `/switch [agent] [model]` | Switch active agent or model mid-conversation |
|
|
93
|
+
| `/handoff [agent] [model]` | Preview a target locally without switching or sending data |
|
|
92
94
|
| `/agents` | Show all detected coding agents, company status, and capabilities |
|
|
93
95
|
| `/models [agent]` | List available models for current or target agent |
|
|
94
96
|
| `/key [provider]` | Enter an API key securely without placing it in prompt history |
|
|
@@ -111,6 +113,39 @@ Short aliases are available for frequent commands: `/a`, `/m`, `/s`, `/k`, `/g`,
|
|
|
111
113
|
|
|
112
114
|
---
|
|
113
115
|
|
|
116
|
+
## Auditable handoffs
|
|
117
|
+
|
|
118
|
+
```text
|
|
119
|
+
/handoff
|
|
120
|
+
/handoff google
|
|
121
|
+
/handoff openai gpt-5.6-terra
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`/handoff` previews the current target; specifying a provider uses its default model
|
|
125
|
+
unless you supply a model ID. Targets can be inspected without credentials or an
|
|
126
|
+
installed provider. `/switch` shows the same report before changing the active agent.
|
|
127
|
+
|
|
128
|
+
The report includes:
|
|
129
|
+
|
|
130
|
+
- Exact counts of transferred user/assistant text messages and excluded stored records.
|
|
131
|
+
- A count of observed transient tool results that are not in portable history. New
|
|
132
|
+
sessions track from creation; older saved sessions explicitly report partial coverage.
|
|
133
|
+
- Capability gains and losses using the local model catalog.
|
|
134
|
+
- Approximate request size including tool schemas, with up to 4,096 tokens reserved
|
|
135
|
+
for an answer in the advisory calculation. This does not change generation settings.
|
|
136
|
+
- SHA-256 of canonical system-prompt and conversational-text JSON. It stays the same
|
|
137
|
+
across targets when that text is unchanged. Tool schemas and attribution metadata
|
|
138
|
+
are intentionally outside this text fingerprint.
|
|
139
|
+
|
|
140
|
+
The preview makes no model requests, saves no transcript exports, executes no tools,
|
|
141
|
+
and does not trim or summarize history. Token sizing uses UTF-8 bytes divided by four
|
|
142
|
+
plus per-message overhead, not a provider tokenizer. Catalog limits can be stale;
|
|
143
|
+
an apparently fitting request can still fail. Warnings are advisory, not switch gates.
|
|
144
|
+
Hidden reasoning and provider-native state do not transfer. The digest proves neither
|
|
145
|
+
delivery nor semantic understanding, and is not a signature or privacy guarantee.
|
|
146
|
+
|
|
147
|
+
See [research and prior art](docs/handoff-research.md) for the differentiation rationale.
|
|
148
|
+
|
|
114
149
|
## Supported Companies and Agents
|
|
115
150
|
|
|
116
151
|
| Company | Agent | Capabilities |
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
11
|
- **Auto-Detection**: Discovers installed AI coding agents (Claude Code, Antigravity, Gemini CLI, OpenAI/Codex, Aider, Copilot, Cursor, Windsurf, Cline, Ollama) and configured credentials.
|
|
12
|
-
- **Context Portability**: Switch
|
|
12
|
+
- **Context Portability**: Switch providers with stored conversational text. Working files remain in the selected local project; they are not automatically uploaded.
|
|
13
|
+
- **Handoff Preview**: Inspect text continuity, omitted state, capability changes, approximate context pressure, and a text fingerprint before switching. Runs locally without model calls.
|
|
13
14
|
- **Dynamic UI**: Terminal interface adapts its status bar, capabilities, and theme based on the active provider.
|
|
14
15
|
- **Capability Disclaimers**: Highlights when an agent lacks specific capabilities such as file editing or code execution.
|
|
15
16
|
- **Rate Limit Failover**: Detects HTTP 429 errors or quota exhaustion and prompts instant switching to ready fallback models.
|
|
@@ -52,6 +53,7 @@ thwip
|
|
|
52
53
|
| Command | Action |
|
|
53
54
|
|:---|:---|
|
|
54
55
|
| `/switch [agent] [model]` | Switch active agent or model mid-conversation |
|
|
56
|
+
| `/handoff [agent] [model]` | Preview a target locally without switching or sending data |
|
|
55
57
|
| `/agents` | Show all detected coding agents, company status, and capabilities |
|
|
56
58
|
| `/models [agent]` | List available models for current or target agent |
|
|
57
59
|
| `/key [provider]` | Enter an API key securely without placing it in prompt history |
|
|
@@ -74,6 +76,39 @@ Short aliases are available for frequent commands: `/a`, `/m`, `/s`, `/k`, `/g`,
|
|
|
74
76
|
|
|
75
77
|
---
|
|
76
78
|
|
|
79
|
+
## Auditable handoffs
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
/handoff
|
|
83
|
+
/handoff google
|
|
84
|
+
/handoff openai gpt-5.6-terra
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`/handoff` previews the current target; specifying a provider uses its default model
|
|
88
|
+
unless you supply a model ID. Targets can be inspected without credentials or an
|
|
89
|
+
installed provider. `/switch` shows the same report before changing the active agent.
|
|
90
|
+
|
|
91
|
+
The report includes:
|
|
92
|
+
|
|
93
|
+
- Exact counts of transferred user/assistant text messages and excluded stored records.
|
|
94
|
+
- A count of observed transient tool results that are not in portable history. New
|
|
95
|
+
sessions track from creation; older saved sessions explicitly report partial coverage.
|
|
96
|
+
- Capability gains and losses using the local model catalog.
|
|
97
|
+
- Approximate request size including tool schemas, with up to 4,096 tokens reserved
|
|
98
|
+
for an answer in the advisory calculation. This does not change generation settings.
|
|
99
|
+
- SHA-256 of canonical system-prompt and conversational-text JSON. It stays the same
|
|
100
|
+
across targets when that text is unchanged. Tool schemas and attribution metadata
|
|
101
|
+
are intentionally outside this text fingerprint.
|
|
102
|
+
|
|
103
|
+
The preview makes no model requests, saves no transcript exports, executes no tools,
|
|
104
|
+
and does not trim or summarize history. Token sizing uses UTF-8 bytes divided by four
|
|
105
|
+
plus per-message overhead, not a provider tokenizer. Catalog limits can be stale;
|
|
106
|
+
an apparently fitting request can still fail. Warnings are advisory, not switch gates.
|
|
107
|
+
Hidden reasoning and provider-native state do not transfer. The digest proves neither
|
|
108
|
+
delivery nor semantic understanding, and is not a signature or privacy guarantee.
|
|
109
|
+
|
|
110
|
+
See [research and prior art](docs/handoff-research.md) for the differentiation rationale.
|
|
111
|
+
|
|
77
112
|
## Supported Companies and Agents
|
|
78
113
|
|
|
79
114
|
| Company | Agent | Capabilities |
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Handoff preview: research and scope
|
|
2
|
+
|
|
3
|
+
Research date: 2026-09-04. This is a bounded public-source comparison, not a patent
|
|
4
|
+
search or proof of worldwide novelty. Search indexes miss unpublished work, private
|
|
5
|
+
products, and features documented under different names. No "world first" claim is made.
|
|
6
|
+
|
|
7
|
+
## What already exists
|
|
8
|
+
|
|
9
|
+
| Primary source | Existing approach | Implication for Thwip |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| [Aider chat modes](https://aider.chat/docs/usage/modes.html) | Architect and editor model pairing | Multi-model collaboration alone is not novel. |
|
|
12
|
+
| [Aider model warnings](https://aider.chat/docs/llms/warnings.html) and [token limits](https://aider.chat/docs/troubleshooting/token-limits.html) | Metadata warnings, token accounting, and overflow guidance | Model-fit diagnostics alone are not novel. |
|
|
13
|
+
| [OpenCode compaction](https://opencode.ai/v2/docs/compaction) | Structured checkpoints plus recent context; durable messages retained separately | Summaries and checkpoints alone are not novel. |
|
|
14
|
+
| [OpenRouter fallbacks](https://openrouter.ai/docs/guides/routing/model-fallbacks) | Ordered fallback models on request failures | Routing and failover alone are not novel. |
|
|
15
|
+
| [OpenRouter message transforms](https://openrouter.ai/docs/guides/features/message-transforms) | Context compression by removing or truncating messages | Automatic fitting can trade away recall. |
|
|
16
|
+
| [Agent Handoff](https://github.com/AniruddhaHumane/handoff) | Portable file-backed resume briefs across agents | Cross-agent memory alone is not novel. |
|
|
17
|
+
| [rosehgal/handoff](https://github.com/rosehgal/handoff) | Append-only action log and rendered handoff document | Tool-action tracking and durable handoffs already exist. |
|
|
18
|
+
|
|
19
|
+
Searches included combinations of "coding agent", "model switch", "handoff",
|
|
20
|
+
"context loss report", "preflight", "capability loss", "fingerprint", and "receipt",
|
|
21
|
+
as well as product-specific documentation queries. Sources were inspected through
|
|
22
|
+
web search retrieval. No restricted pages or private data were scraped.
|
|
23
|
+
|
|
24
|
+
## Chosen differentiation
|
|
25
|
+
|
|
26
|
+
Thwip already owns the provider switch boundary. Put a local continuity report at
|
|
27
|
+
that boundary: exact text-message coverage, explicit state omissions, capability
|
|
28
|
+
differences, advisory context pressure, and a deterministic text fingerprint together.
|
|
29
|
+
The examined sources establish prior art for the components; they did not establish
|
|
30
|
+
the same integrated report. That is an opportunity hypothesis, not proof of uniqueness.
|
|
31
|
+
|
|
32
|
+
## Implementation boundaries
|
|
33
|
+
|
|
34
|
+
- `/handoff [provider] [model]` is a non-mutating dry run. `/switch` displays it too.
|
|
35
|
+
- No new dependencies, model calls, summarization costs, or external data storage.
|
|
36
|
+
- Only a result count is added to saved sessions, not raw tool inputs or outputs.
|
|
37
|
+
- Legacy sessions explicitly show incomplete tool-result tracking.
|
|
38
|
+
- SHA-256 covers canonical system prompt and portable text, not provider wire formats,
|
|
39
|
+
delivery, hidden reasoning, tool schemas, project file contents, or model comprehension.
|
|
40
|
+
- Context estimates are heuristic and warnings advisory. Provider-native tool
|
|
41
|
+
continuation correctness remains a separate issue, not solved by this report.
|
|
42
|
+
- Production deployment and other defects from the previous audit remain separate work.
|
|
43
|
+
|
|
44
|
+
## Validation
|
|
45
|
+
|
|
46
|
+
Offline tests cover stable/change-sensitive fingerprints, exclusions, capability
|
|
47
|
+
gains/losses, context thresholds and unknown limits, unknown targets, private session
|
|
48
|
+
persistence, legacy migration, clear commands, dry-run non-mutation, safe terminal
|
|
49
|
+
rendering, switch integration, and command completion. Live generation is not needed
|
|
50
|
+
to validate this diagnostic, and no claim about live provider success follows from it.
|
|
@@ -67,6 +67,7 @@ async def test_tool_results_are_returned_to_agent(tmp_path):
|
|
|
67
67
|
|
|
68
68
|
assert len(cli.current_agent.calls) == 2
|
|
69
69
|
assert cli.session.messages[-1].content == "Used the tool result."
|
|
70
|
+
assert cli.session.observed_tool_results == 1
|
|
70
71
|
|
|
71
72
|
|
|
72
73
|
class UnconfiguredAgent(ToolCallingAgent):
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Offline regression coverage for auditable text handoffs."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import json
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from io import StringIO
|
|
7
|
+
from types import SimpleNamespace
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from prompt_toolkit.document import Document
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from thwip.agents.base import Capability, ModelInfo
|
|
14
|
+
from thwip.cli import ThwipCLI
|
|
15
|
+
from thwip.handoff import build_handoff_report
|
|
16
|
+
from thwip.session import Message, Session
|
|
17
|
+
from thwip.shortcuts import ThwipCompleter
|
|
18
|
+
from thwip.tools import ToolManager
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OfflineAgent:
|
|
22
|
+
name = "offline"
|
|
23
|
+
company = "Test"
|
|
24
|
+
display_name = "Offline Agent"
|
|
25
|
+
capabilities = {Capability.CHAT, Capability.FILE_EDIT}
|
|
26
|
+
|
|
27
|
+
def __init__(self, window=100_000, tools=True):
|
|
28
|
+
self.model = ModelInfo(id="test-model", name="Test", context_window=window,
|
|
29
|
+
max_output=4096, supports_tools=tools)
|
|
30
|
+
self.available_models = [self.model]
|
|
31
|
+
|
|
32
|
+
def get_model_info(self, model):
|
|
33
|
+
return self.model if model == self.model.id else None
|
|
34
|
+
|
|
35
|
+
def get_default_model(self):
|
|
36
|
+
return self.model.id
|
|
37
|
+
|
|
38
|
+
def get_handoff_models(self):
|
|
39
|
+
return self.available_models
|
|
40
|
+
|
|
41
|
+
def get_capabilities_for_model(self, model):
|
|
42
|
+
return {Capability.CHAT, Capability.FILE_EDIT} if self.model.supports_tools else {Capability.CHAT}
|
|
43
|
+
|
|
44
|
+
def is_installed(self):
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
def is_configured(self):
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_fingerprint_is_stable_and_does_not_mutate_session():
|
|
52
|
+
session = Session(current_model="test-model")
|
|
53
|
+
session.add_user_message("Keep Unicode: नमस्ते")
|
|
54
|
+
session.add_assistant_message("Understood", "offline", "test-model")
|
|
55
|
+
before = deepcopy(session)
|
|
56
|
+
agent = OfflineAgent()
|
|
57
|
+
first = build_handoff_report(session, agent, agent, "test-model")
|
|
58
|
+
second = build_handoff_report(session, agent, agent, "test-model", [{"name": "read"}])
|
|
59
|
+
assert first.text_fingerprint == second.text_fingerprint
|
|
60
|
+
assert second.estimated_input_tokens > first.estimated_input_tokens
|
|
61
|
+
assert session == before
|
|
62
|
+
assert first.transferred_messages == 2
|
|
63
|
+
session.system_prompt += "Changed instruction"
|
|
64
|
+
assert build_handoff_report(session, agent, agent, "test-model").text_fingerprint != first.text_fingerprint
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.mark.parametrize("change", ["text", "order"])
|
|
68
|
+
def test_fingerprint_changes_with_payload(change):
|
|
69
|
+
session = Session()
|
|
70
|
+
session.add_user_message("one")
|
|
71
|
+
session.add_user_message("two")
|
|
72
|
+
agent = OfflineAgent()
|
|
73
|
+
initial = build_handoff_report(session, agent, agent, "test-model")
|
|
74
|
+
if change == "text":
|
|
75
|
+
session.messages[0].content = "different"
|
|
76
|
+
else:
|
|
77
|
+
session.messages.reverse()
|
|
78
|
+
assert build_handoff_report(session, agent, agent, "test-model").text_fingerprint != initial.text_fingerprint
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_exclusions_and_capability_changes_are_explicit():
|
|
82
|
+
session = Session(current_model="test-model")
|
|
83
|
+
session.messages = [Message(role="assistant", content="text", tool_calls=[{"id": "one"}]),
|
|
84
|
+
Message(role="tool", content="private result")]
|
|
85
|
+
session.record_tool_result()
|
|
86
|
+
source, target = OfflineAgent(), OfflineAgent(tools=False)
|
|
87
|
+
report = build_handoff_report(session, source, target, "test-model")
|
|
88
|
+
assert report.transferred_messages == 1
|
|
89
|
+
assert report.excluded_messages == report.excluded_tool_calls == report.observed_tool_results == 1
|
|
90
|
+
assert report.lost_capabilities == (Capability.FILE_EDIT.display_name,)
|
|
91
|
+
assert report.gained_capabilities == ()
|
|
92
|
+
reverse = build_handoff_report(session, target, source, "test-model")
|
|
93
|
+
assert reverse.gained_capabilities == report.lost_capabilities
|
|
94
|
+
assert "private result" not in repr(report)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.mark.parametrize("window,expected", [(0, "unknown"), (100, "likely over budget"),
|
|
98
|
+
(5000, "near limit"), (100000, "below advisory budget")])
|
|
99
|
+
def test_advisory_pressure(window, expected):
|
|
100
|
+
agent = OfflineAgent(window=window)
|
|
101
|
+
report = build_handoff_report(Session(), agent, agent, "test-model")
|
|
102
|
+
assert report.context_pressure == expected
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_unknown_model_is_not_silently_assumed():
|
|
106
|
+
with pytest.raises(ValueError, match="Unknown model"):
|
|
107
|
+
build_handoff_report(Session(), OfflineAgent(), OfflineAgent(), "missing")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_tracking_survives_save_and_legacy_load(tmp_path, monkeypatch):
|
|
111
|
+
monkeypatch.setenv("THWIP_CONFIG_DIR", str(tmp_path))
|
|
112
|
+
session = Session(name="tracking")
|
|
113
|
+
session.record_tool_result()
|
|
114
|
+
path = session.save()
|
|
115
|
+
loaded = Session.load("tracking")
|
|
116
|
+
assert loaded.observed_tool_results == 1
|
|
117
|
+
assert loaded.tool_tracking_complete
|
|
118
|
+
assert path.stat().st_mode & 0o777 == 0o600
|
|
119
|
+
data = json.loads(path.read_text())
|
|
120
|
+
del data["observed_tool_results"]
|
|
121
|
+
del data["tool_tracking_complete"]
|
|
122
|
+
path.write_text(json.dumps(data))
|
|
123
|
+
legacy = Session.load("tracking")
|
|
124
|
+
assert not legacy.tool_tracking_complete
|
|
125
|
+
legacy.record_tool_result()
|
|
126
|
+
assert legacy.observed_tool_results == 1
|
|
127
|
+
legacy.clear_context()
|
|
128
|
+
assert legacy.observed_tool_results == 0
|
|
129
|
+
assert legacy.tool_tracking_complete
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@pytest.mark.asyncio
|
|
133
|
+
async def test_cli_preview_is_offline_non_mutating_and_safe_to_render(tmp_path, monkeypatch):
|
|
134
|
+
agent = OfflineAgent()
|
|
135
|
+
agent.name = "[red]offline[/red]"
|
|
136
|
+
cli = ThwipCLI.__new__(ThwipCLI)
|
|
137
|
+
cli.current_agent = agent
|
|
138
|
+
cli.registry = SimpleNamespace(get_agent=lambda name: agent if name == "offline" else None)
|
|
139
|
+
cli.session = Session(current_model="test-model")
|
|
140
|
+
cli.session.add_user_message("PRIVATE USER TEXT")
|
|
141
|
+
cli.tool_manager = ToolManager(tmp_path)
|
|
142
|
+
buffer = StringIO()
|
|
143
|
+
monkeypatch.setattr("thwip.cli.console", Console(file=buffer, width=140, color_system=None))
|
|
144
|
+
before = deepcopy(cli.session)
|
|
145
|
+
await cli.handle_command("/handoff offline test-model")
|
|
146
|
+
assert cli.session == before
|
|
147
|
+
assert "PRIVATE USER TEXT" not in buffer.getvalue()
|
|
148
|
+
assert "[red]offline[/red]" in buffer.getvalue()
|
|
149
|
+
assert "No model calls" in " ".join(buffer.getvalue().split())
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@pytest.mark.asyncio
|
|
153
|
+
async def test_switch_previews_before_mutating(tmp_path, monkeypatch):
|
|
154
|
+
source, target = OfflineAgent(), OfflineAgent(tools=False)
|
|
155
|
+
cli = ThwipCLI.__new__(ThwipCLI)
|
|
156
|
+
cli.current_agent = source
|
|
157
|
+
cli.registry = SimpleNamespace(get_agent=lambda name: target)
|
|
158
|
+
cli.session = Session(current_model="test-model")
|
|
159
|
+
cli.tool_manager = ToolManager(tmp_path)
|
|
160
|
+
calls = []
|
|
161
|
+
|
|
162
|
+
def preview(*args):
|
|
163
|
+
assert cli.current_agent is source
|
|
164
|
+
calls.append(args)
|
|
165
|
+
|
|
166
|
+
monkeypatch.setattr(cli, "cmd_handoff", preview)
|
|
167
|
+
await cli.cmd_switch("offline", "test-model")
|
|
168
|
+
assert calls == [("offline", "test-model")]
|
|
169
|
+
assert cli.current_agent is target
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@pytest.mark.parametrize("command", ["/clear", "/session clear"])
|
|
173
|
+
@pytest.mark.asyncio
|
|
174
|
+
async def test_clear_commands_reset_tracking(command):
|
|
175
|
+
cli = ThwipCLI.__new__(ThwipCLI)
|
|
176
|
+
cli.session = Session(observed_tool_results=2, tool_tracking_complete=False)
|
|
177
|
+
await cli.handle_command(command)
|
|
178
|
+
assert cli.session.observed_tool_results == 0
|
|
179
|
+
assert cli.session.tool_tracking_complete
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_handoff_completion():
|
|
183
|
+
matches = list(ThwipCompleter(["claude"]).get_completions(Document("/handoff cl"), None))
|
|
184
|
+
assert [match.text for match in matches] == ["/handoff claude"]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_ollama_preview_never_discovers_models_over_network(monkeypatch):
|
|
188
|
+
from thwip.agents.ollama_agent import OllamaAgent
|
|
189
|
+
|
|
190
|
+
def forbidden(*args, **kwargs):
|
|
191
|
+
pytest.fail("Preview must not access the network")
|
|
192
|
+
|
|
193
|
+
monkeypatch.setattr("urllib.request.urlopen", forbidden)
|
|
194
|
+
agent = OllamaAgent(host="https://example.invalid")
|
|
195
|
+
session = Session(current_agent="ollama", current_model="llama3.3")
|
|
196
|
+
report = build_handoff_report(session, agent, agent, "llama3.3")
|
|
197
|
+
assert report.context_pressure == "unknown"
|
|
198
|
+
assert agent._cached_models is None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@pytest.mark.parametrize("module,class_name", [
|
|
202
|
+
("claude", "ClaudeAgent"), ("google", "GoogleAgent"), ("openai", "OpenAIAgent"),
|
|
203
|
+
("deepseek", "DeepSeekAgent"), ("groq", "GroqAgent"), ("ollama", "OllamaAgent"),
|
|
204
|
+
("openrouter", "OpenRouterAgent"),
|
|
205
|
+
])
|
|
206
|
+
def test_every_adapter_supports_offline_catalog_reports(module, class_name, monkeypatch):
|
|
207
|
+
def forbidden(*args, **kwargs):
|
|
208
|
+
pytest.fail("Handoff must not discover providers or generate responses")
|
|
209
|
+
|
|
210
|
+
adapter = getattr(importlib.import_module(f"thwip.agents.{module}_agent"), class_name)
|
|
211
|
+
agent = adapter.__new__(adapter)
|
|
212
|
+
if module == "ollama":
|
|
213
|
+
agent._cached_models = None
|
|
214
|
+
monkeypatch.setattr(agent, "chat", forbidden)
|
|
215
|
+
monkeypatch.setattr(agent, "is_installed", forbidden)
|
|
216
|
+
monkeypatch.setattr(agent, "is_configured", forbidden)
|
|
217
|
+
monkeypatch.setattr("urllib.request.urlopen", forbidden)
|
|
218
|
+
model = agent.get_handoff_models()[0]
|
|
219
|
+
session = Session(current_agent=agent.name, current_model=model.id)
|
|
220
|
+
report = build_handoff_report(session, agent, agent, model.id)
|
|
221
|
+
assert report.lost_capabilities == report.gained_capabilities == ()
|
|
222
|
+
assert len(report.text_fingerprint) == 64
|
|
@@ -298,6 +298,10 @@ class BaseAgent(ABC):
|
|
|
298
298
|
return m
|
|
299
299
|
return None
|
|
300
300
|
|
|
301
|
+
def get_handoff_models(self) -> list[ModelInfo]:
|
|
302
|
+
"""Return local catalog metadata without requesting provider discovery."""
|
|
303
|
+
return list(self.available_models)
|
|
304
|
+
|
|
301
305
|
def has_capability(self, cap: Capability) -> bool:
|
|
302
306
|
"""Check if this agent supports a capability."""
|
|
303
307
|
return cap in self.capabilities
|
|
@@ -98,6 +98,17 @@ class OllamaAgent(BaseAgent):
|
|
|
98
98
|
def is_installed(self) -> bool:
|
|
99
99
|
return shutil.which("ollama") is not None or self._is_server_reachable()
|
|
100
100
|
|
|
101
|
+
def get_handoff_models(self) -> list[ModelInfo]:
|
|
102
|
+
"""Never query even a remote configured Ollama host during a preview."""
|
|
103
|
+
if self._cached_models is not None:
|
|
104
|
+
return list(self._cached_models)
|
|
105
|
+
return [
|
|
106
|
+
ModelInfo(id="llama3.3", name="Llama 3.3", is_default=True),
|
|
107
|
+
ModelInfo(id="qwen2.5-coder", name="Qwen 2.5 Coder"),
|
|
108
|
+
ModelInfo(id="deepseek-r1", name="DeepSeek R1 Distill"),
|
|
109
|
+
ModelInfo(id="codellama", name="CodeLlama"),
|
|
110
|
+
]
|
|
111
|
+
|
|
101
112
|
def _is_server_reachable(self) -> bool:
|
|
102
113
|
try:
|
|
103
114
|
req = urllib.request.Request(f"{self.host}/api/tags")
|
|
@@ -38,6 +38,7 @@ from thwip.agents.base import (
|
|
|
38
38
|
)
|
|
39
39
|
from thwip.config import ThwipConfig, get_config_dir
|
|
40
40
|
from thwip.detector import SystemDetector
|
|
41
|
+
from thwip.handoff import build_handoff_report, local_capabilities, local_model
|
|
41
42
|
from thwip.limits import UsageTracker
|
|
42
43
|
from thwip.session import Session
|
|
43
44
|
from thwip.shortcuts import ThwipCompleter, create_keybindings
|
|
@@ -210,6 +211,9 @@ class ThwipCLI:
|
|
|
210
211
|
elif cmd in ("/switch", "/s"):
|
|
211
212
|
await self.cmd_switch(arg1, arg2)
|
|
212
213
|
|
|
214
|
+
elif cmd == "/handoff":
|
|
215
|
+
self.cmd_handoff(arg1, arg2)
|
|
216
|
+
|
|
213
217
|
elif cmd in ("/agents", "/list", "/a"):
|
|
214
218
|
self.cmd_show_agents()
|
|
215
219
|
|
|
@@ -235,7 +239,7 @@ class ThwipCLI:
|
|
|
235
239
|
self.cmd_show_history()
|
|
236
240
|
|
|
237
241
|
elif cmd in ("/clear", "/reset"):
|
|
238
|
-
self.session.
|
|
242
|
+
self.session.clear_context()
|
|
239
243
|
print_info("Conversation history cleared.")
|
|
240
244
|
|
|
241
245
|
elif cmd == "/cost":
|
|
@@ -272,7 +276,7 @@ class ThwipCLI:
|
|
|
272
276
|
elif sub == "list":
|
|
273
277
|
self.cmd_list_sessions()
|
|
274
278
|
elif sub == "clear":
|
|
275
|
-
self.session.
|
|
279
|
+
self.session.clear_context()
|
|
276
280
|
print_info("Conversation history cleared.")
|
|
277
281
|
else:
|
|
278
282
|
print_info("Usage: /session [save|load|list|clear] [name]")
|
|
@@ -326,7 +330,8 @@ class ThwipCLI:
|
|
|
326
330
|
|
|
327
331
|
commands = [
|
|
328
332
|
("/about", "Display full About section, architecture, and navigation guide"),
|
|
329
|
-
("/switch [agent] [model]", "Switch
|
|
333
|
+
("/switch [agent] [model]", "Switch provider with a text-continuity report"),
|
|
334
|
+
("/handoff [agent] [model]", "Preview transfer losses and context pressure without switching"),
|
|
330
335
|
("/agents", "Show all detected coding agents, company status & capabilities"),
|
|
331
336
|
("/models [agent|tier]", "List models filtered by provider or tier (flagship, balanced, fast)"),
|
|
332
337
|
("/key [provider]", "Securely enter an API key without storing it in terminal history"),
|
|
@@ -350,6 +355,54 @@ class ThwipCLI:
|
|
|
350
355
|
table.add_row(c, d)
|
|
351
356
|
console.print(table)
|
|
352
357
|
|
|
358
|
+
def cmd_handoff(self, agent_name: str = "", model_id: str = "") -> None:
|
|
359
|
+
"""Preview any catalogued target without requiring credentials or API calls."""
|
|
360
|
+
target = self.registry.get_agent(agent_name) if agent_name else self.current_agent
|
|
361
|
+
if target is None:
|
|
362
|
+
print_error(f"Unknown agent '{agent_name}'.")
|
|
363
|
+
return
|
|
364
|
+
models = target.get_handoff_models()
|
|
365
|
+
default = next((model.id for model in models if model.is_default), models[0].id if models else "")
|
|
366
|
+
chosen = model_id or (self.session.current_model if not agent_name else default)
|
|
367
|
+
if local_model(target, chosen) is None:
|
|
368
|
+
print_error(f"Unknown model '{chosen}' for {target.display_name}.")
|
|
369
|
+
return
|
|
370
|
+
caps = local_capabilities(target, chosen)
|
|
371
|
+
tools = None
|
|
372
|
+
if Capability.FILE_EDIT in caps:
|
|
373
|
+
tools = (
|
|
374
|
+
self.tool_manager.get_anthropic_tools() if target.name == "claude"
|
|
375
|
+
else self.tool_manager.get_openai_tools()
|
|
376
|
+
)
|
|
377
|
+
report = build_handoff_report(self.session, self.current_agent, target, chosen, tools)
|
|
378
|
+
table = Table(title="Handoff Preview (local only)", box=box.ROUNDED)
|
|
379
|
+
table.add_column("Check", style="cyan")
|
|
380
|
+
table.add_column("Result")
|
|
381
|
+
rows = [
|
|
382
|
+
("Route", f"{report.source} -> {report.target}"),
|
|
383
|
+
("Preserved", f"{report.transferred_messages} user/assistant text messages + system prompt"),
|
|
384
|
+
("Excluded records", (f"{report.excluded_messages} non-text-history messages; "
|
|
385
|
+
f"{report.excluded_tool_calls} stored tool-call entries")),
|
|
386
|
+
("Transient results", f"{report.observed_tool_results} observed tool results not transferred"),
|
|
387
|
+
("Tracking coverage", "Since session creation" if report.tracking_complete
|
|
388
|
+
else "Partial: legacy session has uncounted earlier tool results"),
|
|
389
|
+
("Capabilities lost", ", ".join(report.lost_capabilities) or "None in local catalog"),
|
|
390
|
+
("Capabilities gained", ", ".join(report.gained_capabilities) or "None in local catalog"),
|
|
391
|
+
("Context pressure", (f"{report.context_pressure}: ~{report.estimated_input_tokens:,} input "
|
|
392
|
+
f"+ {report.output_reserve:,} advisory output reserve / "
|
|
393
|
+
f"{report.context_window or 'unknown'} catalog tokens")),
|
|
394
|
+
("Text SHA-256", report.text_fingerprint),
|
|
395
|
+
]
|
|
396
|
+
for label, value in rows:
|
|
397
|
+
table.add_row(Text(label), Text(value))
|
|
398
|
+
console.print(table)
|
|
399
|
+
console.print(Text(
|
|
400
|
+
"Advisory only: token estimates and catalog limits can differ from provider behavior. "
|
|
401
|
+
"Hidden reasoning and provider-native state do not transfer. Working files stay on disk; "
|
|
402
|
+
"they are not uploaded by this preview. The fingerprint checks text equality, not delivery. "
|
|
403
|
+
"No model calls, trimming, or switching were performed by the preview.", style="dim",
|
|
404
|
+
))
|
|
405
|
+
|
|
353
406
|
async def cmd_switch(self, agent_name: str, model_id: str = "") -> None:
|
|
354
407
|
"""Switch to a different agent and/or model."""
|
|
355
408
|
if not agent_name:
|
|
@@ -408,6 +461,8 @@ class ThwipCLI:
|
|
|
408
461
|
old_agent = self.current_agent
|
|
409
462
|
old_caps = old_agent.get_capabilities_for_model(self.session.current_model)
|
|
410
463
|
|
|
464
|
+
self.cmd_handoff(new_agent.name, chosen_model)
|
|
465
|
+
|
|
411
466
|
self.current_agent = new_agent
|
|
412
467
|
self.session.switch_agent(new_agent.name, chosen_model)
|
|
413
468
|
|
|
@@ -867,6 +922,7 @@ class ThwipCLI:
|
|
|
867
922
|
if approved
|
|
868
923
|
else "Denied by user."
|
|
869
924
|
)
|
|
925
|
+
self.session.record_tool_result()
|
|
870
926
|
result_preview = Text(" Result: ", style="green")
|
|
871
927
|
result_preview.append(output[:500], style="dim")
|
|
872
928
|
console.print(result_preview)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Local, non-mutating diagnostics for provider-neutral conversation transfers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from thwip.agents.base import BaseAgent, Capability, ModelInfo
|
|
12
|
+
from thwip.session import Session
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class HandoffReport:
|
|
17
|
+
source: str
|
|
18
|
+
target: str
|
|
19
|
+
text_fingerprint: str
|
|
20
|
+
transferred_messages: int
|
|
21
|
+
excluded_messages: int
|
|
22
|
+
excluded_tool_calls: int
|
|
23
|
+
observed_tool_results: int
|
|
24
|
+
tracking_complete: bool
|
|
25
|
+
lost_capabilities: tuple[str, ...]
|
|
26
|
+
gained_capabilities: tuple[str, ...]
|
|
27
|
+
estimated_input_tokens: int
|
|
28
|
+
context_window: int
|
|
29
|
+
output_reserve: int
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def context_pressure(self) -> str:
|
|
33
|
+
if self.context_window <= 0:
|
|
34
|
+
return "unknown"
|
|
35
|
+
fraction = (self.estimated_input_tokens + self.output_reserve) / self.context_window
|
|
36
|
+
if fraction > 1:
|
|
37
|
+
return "likely over budget"
|
|
38
|
+
if fraction >= 0.8:
|
|
39
|
+
return "near limit"
|
|
40
|
+
return "below advisory budget"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def local_model(agent: BaseAgent, model_id: str) -> ModelInfo | None:
|
|
44
|
+
return next((model for model in agent.get_handoff_models() if model.id == model_id), None)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def local_capabilities(agent: BaseAgent, model_id: str) -> set[Capability]:
|
|
48
|
+
model = local_model(agent, model_id)
|
|
49
|
+
if model and not model.supports_tools:
|
|
50
|
+
return {Capability.CHAT}
|
|
51
|
+
return set(agent.capabilities)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_handoff_report(
|
|
55
|
+
session: Session,
|
|
56
|
+
source: BaseAgent,
|
|
57
|
+
target: BaseAgent,
|
|
58
|
+
model_id: str,
|
|
59
|
+
tools: list[dict[str, Any]] | None = None,
|
|
60
|
+
) -> HandoffReport:
|
|
61
|
+
"""Fingerprint text only; estimate request size without contacting a provider.
|
|
62
|
+
|
|
63
|
+
The digest is an equality check, not a signature, confidentiality mechanism,
|
|
64
|
+
or proof that the target provider received or understood the context.
|
|
65
|
+
"""
|
|
66
|
+
model = local_model(target, model_id)
|
|
67
|
+
if model is None:
|
|
68
|
+
raise ValueError(f"Unknown model '{model_id}' for {target.name}.")
|
|
69
|
+
portable = session.to_portable_messages()
|
|
70
|
+
text = {"system_prompt": session.system_prompt, "messages": portable}
|
|
71
|
+
canonical = json.dumps(text, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
72
|
+
request = json.dumps({**text, "tools": tools or []}, ensure_ascii=False)
|
|
73
|
+
# This heuristic is advisory, especially for code and non-Latin languages.
|
|
74
|
+
estimate = math.ceil(len(request.encode("utf-8")) / 4) + 8 * len(portable)
|
|
75
|
+
old = local_capabilities(source, session.current_model)
|
|
76
|
+
new = local_capabilities(target, model_id)
|
|
77
|
+
return HandoffReport(
|
|
78
|
+
source=f"{source.name}/{session.current_model}",
|
|
79
|
+
target=f"{target.name}/{model_id}",
|
|
80
|
+
text_fingerprint=hashlib.sha256(canonical.encode("utf-8")).hexdigest(),
|
|
81
|
+
transferred_messages=len(portable),
|
|
82
|
+
excluded_messages=len(session.messages) - len(portable),
|
|
83
|
+
excluded_tool_calls=sum(len(message.tool_calls) for message in session.messages),
|
|
84
|
+
observed_tool_results=session.observed_tool_results,
|
|
85
|
+
tracking_complete=session.tool_tracking_complete,
|
|
86
|
+
lost_capabilities=tuple(sorted(cap.display_name for cap in old - new)),
|
|
87
|
+
gained_capabilities=tuple(sorted(cap.display_name for cap in new - old)),
|
|
88
|
+
estimated_input_tokens=estimate,
|
|
89
|
+
context_window=model.context_window,
|
|
90
|
+
output_reserve=min(4096, model.max_output) if model.max_output > 0 else 4096,
|
|
91
|
+
)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Session & conversation state manager for thwip.
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
Preserves conversational text across provider switches, not native reasoning state.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
@@ -63,6 +63,20 @@ class Session:
|
|
|
63
63
|
created_at: float = field(default_factory=time.time)
|
|
64
64
|
updated_at: float = field(default_factory=time.time)
|
|
65
65
|
messages: list[Message] = field(default_factory=list)
|
|
66
|
+
observed_tool_results: int = 0
|
|
67
|
+
tool_tracking_complete: bool = True
|
|
68
|
+
|
|
69
|
+
def clear_context(self) -> None:
|
|
70
|
+
"""Clear text and its associated handoff accounting together."""
|
|
71
|
+
self.messages.clear()
|
|
72
|
+
self.observed_tool_results = 0
|
|
73
|
+
self.tool_tracking_complete = True
|
|
74
|
+
self.updated_at = time.time()
|
|
75
|
+
|
|
76
|
+
def record_tool_result(self) -> None:
|
|
77
|
+
"""Count transient results without persisting potentially sensitive outputs."""
|
|
78
|
+
self.observed_tool_results += 1
|
|
79
|
+
self.updated_at = time.time()
|
|
66
80
|
|
|
67
81
|
def add_user_message(self, content: str) -> Message:
|
|
68
82
|
msg = Message(role="user", content=content)
|
|
@@ -102,7 +116,7 @@ class Session:
|
|
|
102
116
|
return portable
|
|
103
117
|
|
|
104
118
|
def switch_agent(self, agent_name: str, model: str) -> None:
|
|
105
|
-
"""Switch current agent and model while preserving
|
|
119
|
+
"""Switch current agent and model while preserving stored text history."""
|
|
106
120
|
self.current_agent = agent_name
|
|
107
121
|
self.current_model = model
|
|
108
122
|
self.updated_at = time.time()
|
|
@@ -128,6 +142,8 @@ class Session:
|
|
|
128
142
|
"created_at": self.created_at,
|
|
129
143
|
"updated_at": self.updated_at,
|
|
130
144
|
"messages": [m.to_dict() for m in self.messages],
|
|
145
|
+
"observed_tool_results": self.observed_tool_results,
|
|
146
|
+
"tool_tracking_complete": self.tool_tracking_complete,
|
|
131
147
|
}
|
|
132
148
|
temporary_path: Path | None = None
|
|
133
149
|
try:
|
|
@@ -180,6 +196,8 @@ class Session:
|
|
|
180
196
|
created_at=data.get("created_at", time.time()),
|
|
181
197
|
updated_at=data.get("updated_at", time.time()),
|
|
182
198
|
messages=[Message.from_dict(m) for m in data.get("messages", [])],
|
|
199
|
+
observed_tool_results=data.get("observed_tool_results", 0),
|
|
200
|
+
tool_tracking_complete=data.get("tool_tracking_complete", False),
|
|
183
201
|
)
|
|
184
202
|
return session
|
|
185
203
|
except Exception:
|
|
@@ -9,6 +9,7 @@ from prompt_toolkit.key_binding import KeyBindings
|
|
|
9
9
|
|
|
10
10
|
SLASH_COMMANDS = [
|
|
11
11
|
("/switch", "Switch agent and model mid-conversation"),
|
|
12
|
+
("/handoff", "Preview context transfer, losses, and model fit locally"),
|
|
12
13
|
("/agents", "Show all detected coding agents & status"),
|
|
13
14
|
("/models", "List available models for current agent"),
|
|
14
15
|
("/status", "Display current session and agent info"),
|
|
@@ -41,12 +42,12 @@ class ThwipCompleter(Completer):
|
|
|
41
42
|
yield Completion(cmd, start_position=-len(text), display_meta=desc)
|
|
42
43
|
|
|
43
44
|
# /switch <agent> autocompletion
|
|
44
|
-
if text.startswith("/switch "):
|
|
45
|
+
if text.startswith(("/switch ", "/handoff ")):
|
|
45
46
|
parts = text.split(" ")
|
|
46
47
|
prefix = parts[1] if len(parts) > 1 else ""
|
|
47
48
|
for name in self.agent_names:
|
|
48
49
|
if name.startswith(prefix):
|
|
49
|
-
yield Completion(f"
|
|
50
|
+
yield Completion(f"{parts[0]} {name}", start_position=-len(text), display_meta="Agent")
|
|
50
51
|
|
|
51
52
|
|
|
52
53
|
def create_keybindings(on_switch=None, on_status=None, on_history=None) -> KeyBindings:
|
|
@@ -198,7 +198,7 @@ def render_about_guide(
|
|
|
198
198
|
content.append("HOW IT WORKS\n", style="bold cyan")
|
|
199
199
|
content.append(" 1. Auto-Detection: Scans environment variables, local CLI agents, and existing credentials\n", style="dim")
|
|
200
200
|
content.append(" in ~/.claude.json, ~/.gemini/config.json, ~/.config/openai/, or Ollama.\n", style="dim")
|
|
201
|
-
content.append(" 2. Unified State:
|
|
201
|
+
content.append(" 2. Unified State: Preserves text history; /handoff previews excluded state and model fit.\n", style="dim")
|
|
202
202
|
content.append(" 3. Hot-Swapping: Switch models with completed text history preserved.\n", style="dim")
|
|
203
203
|
content.append(" 4. Universal Tools: Provides safe file editing, shell commands, execution, and git operations.\n", style="dim")
|
|
204
204
|
content.append(" 5. Quota Failover: Catches HTTP 429 errors and offers immediate one-key fallback switching.\n\n", style="dim")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|