python-agent-harness 1.5.6.4__tar.gz → 1.5.6.5__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.
- {python_agent_harness-1.5.6.4/python_agent_harness.egg-info → python_agent_harness-1.5.6.5}/PKG-INFO +2 -2
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/README.md +1 -1
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/pyproject.toml +1 -1
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/__init__.py +1 -1
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/core/agent.py +6 -2
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/core/models.py +16 -6
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/core/token_estimator.py +4 -1
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/entry/cli.py +12 -4
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/entry/headless.py +39 -72
- python_agent_harness-1.5.6.5/python_agent_harness/entry/restore.py +149 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/entry/server.py +10 -13
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/session/config.py +63 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/session/session.py +41 -2
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/bash.py +107 -1
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/grep.py +14 -6
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/grep_mac.py +13 -61
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tui/commands.py +25 -105
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tui/render.py +26 -3
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5/python_agent_harness.egg-info}/PKG-INFO +2 -2
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness.egg-info/SOURCES.txt +1 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/tests/test_scenarios.py +58 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/LICENSE +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/__main__.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/core/__init__.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/core/context_manager.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/core/planmode.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/core/subagent.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/core/tool_runner.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/entry/__init__.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/entry/controller.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/entry/view.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/io/__init__.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/io/attachments.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/io/diffrender.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/io/persistence.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/io/text_filter.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/llm/__init__.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/llm/client.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/mcp/__init__.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/mcp/client.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/mcp/config.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/mcp/manager.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/__init__.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/agent.md +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/agents/reviewer.md +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/build-switch.md +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/commands/explain.md +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/compact.md +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/core.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/initialize.md +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/plan-mode.md +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/plan.md +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/review.md +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/subagent.md +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/summary.md +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/task-completion-rules.md +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/title.md +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/session/__init__.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/session/commands.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/__init__.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/agent_tool.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/base.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/diffapply.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/edit.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/edit_mac.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/filesystem.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/glob.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/glob_mac.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/mcp.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/planexit.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/question.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/read.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/skill.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/todo.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/write.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tui/__init__.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tui/core.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tui/input.py +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness.egg-info/dependency_links.txt +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness.egg-info/entry_points.txt +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness.egg-info/requires.txt +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness.egg-info/top_level.txt +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/setup.cfg +0 -0
- {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/tests/test_invariants.py +0 -0
{python_agent_harness-1.5.6.4/python_agent_harness.egg-info → python_agent_harness-1.5.6.5}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-agent-harness
|
|
3
|
-
Version: 1.5.6.
|
|
3
|
+
Version: 1.5.6.5
|
|
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
|
|
@@ -211,7 +211,7 @@ Most of [opencode](https://github.com/anomalyco/opencode)'s prompts and core beh
|
|
|
211
211
|
| `shell.txt` | `Bash` tool + `agent.md` Git/GitHub guidance |
|
|
212
212
|
| `plan-enter.txt` / `plan-exit.txt` | `PlanExit` tool |
|
|
213
213
|
| `initialize.txt` / `review.txt` / `explain` | `initialize.md` / `review.md` / `commands/explain.md` |
|
|
214
|
-
| compaction / summary / title | `compact.md` / `summary.md` / `title.md` |
|
|
214
|
+
| `compaction` / `summary` / `title` | `compact.md` / `summary.md` / `title.md` |
|
|
215
215
|
| `AGENTS.md` handling | `prompts.py` (`find_agents_md_files`, `load_context_files`) |
|
|
216
216
|
|
|
217
217
|
## Development
|
|
@@ -177,7 +177,7 @@ Most of [opencode](https://github.com/anomalyco/opencode)'s prompts and core beh
|
|
|
177
177
|
| `shell.txt` | `Bash` tool + `agent.md` Git/GitHub guidance |
|
|
178
178
|
| `plan-enter.txt` / `plan-exit.txt` | `PlanExit` tool |
|
|
179
179
|
| `initialize.txt` / `review.txt` / `explain` | `initialize.md` / `review.md` / `commands/explain.md` |
|
|
180
|
-
| compaction / summary / title | `compact.md` / `summary.md` / `title.md` |
|
|
180
|
+
| `compaction` / `summary` / `title` | `compact.md` / `summary.md` / `title.md` |
|
|
181
181
|
| `AGENTS.md` handling | `prompts.py` (`find_agents_md_files`, `load_context_files`) |
|
|
182
182
|
|
|
183
183
|
## Development
|
|
@@ -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.
|
|
7
|
+
version = "1.5.6.5"
|
|
8
8
|
description = "A lightweight, hackable mini-OpenCode written in Python."
|
|
9
9
|
authors = [
|
|
10
10
|
{name = "Huming Chen", email = "chenhuming@gmail.com"}
|
{python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/core/agent.py
RENAMED
|
@@ -471,8 +471,9 @@ class AgentLoop:
|
|
|
471
471
|
# after successful responses, so without this the
|
|
472
472
|
# interrupted turn would never reach the session
|
|
473
473
|
# file — the next turn's save would overwrite it
|
|
474
|
-
# without ever containing it.
|
|
475
|
-
|
|
474
|
+
# without ever containing it. flush bypasses the
|
|
475
|
+
# debounce so the partial turn is not lost.
|
|
476
|
+
session.flush_auto_save(salvaged)
|
|
476
477
|
# The title is generated on the first save, not
|
|
477
478
|
# only on clean completion — an interrupted session
|
|
478
479
|
# still gets a meaningful name (one-shot; no-op when
|
|
@@ -484,6 +485,9 @@ class AgentLoop:
|
|
|
484
485
|
# no-op when the title already exists or generation
|
|
485
486
|
# is in flight)
|
|
486
487
|
session.generate_session_title()
|
|
488
|
+
# The per-round auto_save is debounced; flush at run
|
|
489
|
+
# end so the final state always reaches the file.
|
|
490
|
+
session.flush_auto_save(salvaged)
|
|
487
491
|
return self.result
|
|
488
492
|
|
|
489
493
|
def _next_state(self) -> str:
|
{python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/core/models.py
RENAMED
|
@@ -69,6 +69,15 @@ class ImagePart:
|
|
|
69
69
|
path: str | None = None
|
|
70
70
|
url: str | None = None
|
|
71
71
|
|
|
72
|
+
def __post_init__(self) -> None:
|
|
73
|
+
# to_api() runs on every round (token estimation + API payload),
|
|
74
|
+
# so the base64 data URL is encoded once, not per call.
|
|
75
|
+
self._data_url: str | None = None
|
|
76
|
+
if self.data is not None:
|
|
77
|
+
self._data_url = (
|
|
78
|
+
f"data:{self.media_type};base64,{base64.b64encode(self.data).decode('ascii')}"
|
|
79
|
+
)
|
|
80
|
+
|
|
72
81
|
@classmethod
|
|
73
82
|
def from_url(cls, url: str) -> ImagePart:
|
|
74
83
|
return cls(url=url)
|
|
@@ -76,13 +85,9 @@ class ImagePart:
|
|
|
76
85
|
def to_api(self) -> dict[str, Any]:
|
|
77
86
|
if self.url:
|
|
78
87
|
return {"type": "image_url", "image_url": {"url": self.url}}
|
|
79
|
-
if self.
|
|
88
|
+
if self._data_url is None:
|
|
80
89
|
raise ValueError("ImagePart requires either data or url")
|
|
81
|
-
|
|
82
|
-
return {
|
|
83
|
-
"type": "image_url",
|
|
84
|
-
"image_url": {"url": f"data:{self.media_type};base64,{b64}"},
|
|
85
|
-
}
|
|
90
|
+
return {"type": "image_url", "image_url": {"url": self._data_url}}
|
|
86
91
|
|
|
87
92
|
|
|
88
93
|
# Type alias for the individual parts a Message.content list may contain.
|
|
@@ -155,6 +160,11 @@ class Message:
|
|
|
155
160
|
injected: bool = False # harness-injected (nudge/plan/build-switch), not user input
|
|
156
161
|
|
|
157
162
|
def to_api(self) -> dict[str, Any]:
|
|
163
|
+
# Not cached: the per-message dict is cheap to rebuild and
|
|
164
|
+
# caching it (returning the same object each call) would make a
|
|
165
|
+
# mutation by any caller corrupt later rounds and token
|
|
166
|
+
# estimation. The one expensive step — base64-encoding an image
|
|
167
|
+
# — is cached on ``ImagePart`` instead (see ImagePart.__post_init__).
|
|
158
168
|
d: dict[str, Any] = {"role": self.role}
|
|
159
169
|
if self.content is not None:
|
|
160
170
|
content = self._api_content()
|
|
@@ -45,7 +45,10 @@ def estimate_tokens(text: str) -> int:
|
|
|
45
45
|
"""Estimate tokens in TEXT: Latin ~4 chars/token, CJK ~2 chars/token."""
|
|
46
46
|
if not text:
|
|
47
47
|
return 0
|
|
48
|
-
|
|
48
|
+
# subn counts CJK chars in one C-speed pass without allocating the
|
|
49
|
+
# per-match list findall would build (this runs on the full payload
|
|
50
|
+
# every round).
|
|
51
|
+
cjk = _CJK_RE.subn("", text)[1]
|
|
49
52
|
latin = len(text) - cjk
|
|
50
53
|
return round(latin / 4.0 + cjk / 2.0)
|
|
51
54
|
|
{python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/entry/cli.py
RENAMED
|
@@ -272,12 +272,20 @@ def cmd_serve(args: argparse.Namespace) -> int:
|
|
|
272
272
|
def cmd_config(args: argparse.Namespace) -> int:
|
|
273
273
|
path = config._config_path(args.path)
|
|
274
274
|
if args.init:
|
|
275
|
-
|
|
276
|
-
|
|
275
|
+
# 0o700 dir / 0o600 file: the config holds the API key, so it must
|
|
276
|
+
# not be group/world-readable (mode applies to a NEW leaf dir only;
|
|
277
|
+
# an existing directory keeps its permissions).
|
|
278
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
279
|
+
template = config.CONFIG_TEMPLATE.format(path=str(path).replace("\\", "\\\\"))
|
|
280
|
+
try:
|
|
281
|
+
# O_EXCL: create-only, atomically -- no exists()/write race, and
|
|
282
|
+
# a pre-planted symlink at PATH is refused instead of followed.
|
|
283
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
284
|
+
except FileExistsError:
|
|
277
285
|
print(f"config already exists: {path} (delete it first to overwrite)")
|
|
278
286
|
return 1
|
|
279
|
-
|
|
280
|
-
|
|
287
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
288
|
+
f.write(template)
|
|
281
289
|
print(f"wrote config template: {path}")
|
|
282
290
|
return 0
|
|
283
291
|
settings = config.load_llm_config(args.path)
|
{python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/entry/headless.py
RENAMED
|
@@ -137,6 +137,25 @@ def _cancelled_now(session: Any) -> bool:
|
|
|
137
137
|
return event is not None and event.is_set() is True
|
|
138
138
|
|
|
139
139
|
|
|
140
|
+
def usage_snapshot(session: Any) -> dict[str, Any] | None:
|
|
141
|
+
"""Cumulative input/output tokens and rounds of the current run.
|
|
142
|
+
|
|
143
|
+
Reads the CURRENT ``usage_totals`` (``Controller.submit`` swaps in a
|
|
144
|
+
fresh dict per run, so a reference captured before submit would
|
|
145
|
+
always snapshot zeros). None when the session keeps no totals.
|
|
146
|
+
"""
|
|
147
|
+
totals = getattr(session, "usage_totals", None)
|
|
148
|
+
if not isinstance(totals, dict):
|
|
149
|
+
return None
|
|
150
|
+
usage: dict[str, Any] = {"input": 0, "output": 0, "rounds": 0}
|
|
151
|
+
with totals.get("_lock", threading.Lock()):
|
|
152
|
+
for key in ("input", "output", "rounds"):
|
|
153
|
+
value = totals.get(key)
|
|
154
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
155
|
+
usage[key] = int(value)
|
|
156
|
+
return usage
|
|
157
|
+
|
|
158
|
+
|
|
140
159
|
def final_answer_text(session: Session) -> str:
|
|
141
160
|
"""The run's final assistant answer, TUI-filtered.
|
|
142
161
|
|
|
@@ -285,69 +304,25 @@ def restore_session(controller: Controller, spec: str, err: TextIO) -> bool:
|
|
|
285
304
|
file, and restores the saved agent/model. Returns False (with a
|
|
286
305
|
message on *err*) when no matching session exists.
|
|
287
306
|
"""
|
|
288
|
-
from
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
parse_saved_body,
|
|
292
|
-
title_from_filename,
|
|
293
|
-
)
|
|
294
|
-
|
|
295
|
-
if not spec or spec in ("--latest", "latest"):
|
|
296
|
-
path = SessionPersistence.latest_session()
|
|
297
|
-
elif os.path.isfile(os.path.expanduser(spec)):
|
|
298
|
-
path = os.path.expanduser(spec)
|
|
299
|
-
else:
|
|
300
|
-
path = find_session_by_title(spec)
|
|
307
|
+
from .restore import apply_saved_session, load_saved_session, resolve_session_path
|
|
308
|
+
|
|
309
|
+
path = resolve_session_path(spec)
|
|
301
310
|
if not path or not os.path.isfile(path):
|
|
302
311
|
err.write(f"no session found: {spec or 'latest'}\n")
|
|
303
312
|
return False
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
title = title_from_filename(path)
|
|
314
|
-
if title:
|
|
315
|
-
controller.store.title = title
|
|
316
|
-
agent = meta.get("python-agent-harness--agent")
|
|
317
|
-
if agent:
|
|
318
|
-
success, msg = controller.switch_agent(agent)
|
|
319
|
-
if not success:
|
|
320
|
-
controller.switch_agent("default")
|
|
313
|
+
try:
|
|
314
|
+
saved = load_saved_session(path)
|
|
315
|
+
except OSError as e:
|
|
316
|
+
err.write(f"cannot read {path}: {e}\n")
|
|
317
|
+
return False
|
|
318
|
+
|
|
319
|
+
def report(level: str, msg: str) -> None:
|
|
320
|
+
# headless output is quiet on success: only problems reach stderr
|
|
321
|
+
if level == "warning":
|
|
321
322
|
err.write(f"warning: {msg}\n")
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
# The saved model is the session-start default: the
|
|
326
|
-
# "default" pseudo-profile restores exactly those settings.
|
|
327
|
-
controller.switch_model("default")
|
|
328
|
-
else:
|
|
329
|
-
success, _ = controller.switch_model(model)
|
|
330
|
-
if not success:
|
|
331
|
-
profile = next(
|
|
332
|
-
(
|
|
333
|
-
name
|
|
334
|
-
for name, p in controller.model_profiles.items()
|
|
335
|
-
if p.get("model") == model
|
|
336
|
-
),
|
|
337
|
-
None,
|
|
338
|
-
)
|
|
339
|
-
if profile:
|
|
340
|
-
success, _ = controller.switch_model(profile)
|
|
341
|
-
if not success:
|
|
342
|
-
err.write(
|
|
343
|
-
f"warning: model {model!r} has no matching profile, keeping current model\n"
|
|
344
|
-
)
|
|
345
|
-
# Replace conversation history: a new generation, so a stale worker
|
|
346
|
-
# from a previous run can't clobber the restored session.
|
|
347
|
-
controller.run_generation += 1
|
|
348
|
-
controller.last_messages = messages
|
|
349
|
-
controller.clear_todos()
|
|
350
|
-
err.write(f"restored: {os.path.basename(path)} ({len(messages)} messages)\n")
|
|
323
|
+
|
|
324
|
+
apply_saved_session(controller, saved, report)
|
|
325
|
+
err.write(f"restored: {os.path.basename(path)} ({len(saved.messages)} messages)\n")
|
|
351
326
|
return True
|
|
352
327
|
|
|
353
328
|
|
|
@@ -679,20 +654,12 @@ def run_headless_jsonl(
|
|
|
679
654
|
"""The terminal result line: snapshots usage and model so every
|
|
680
655
|
result line — including early failures — carries the same
|
|
681
656
|
fields for the driving process."""
|
|
682
|
-
usage: dict[str, Any] | None = None
|
|
683
|
-
# Read the CURRENT usage_totals, not a reference captured before
|
|
684
|
-
# submit(): Controller.submit swaps in a fresh dict per run, so
|
|
685
|
-
# a pre-submit reference would always snapshot zeros.
|
|
686
|
-
totals = getattr(session, "usage_totals", None)
|
|
687
|
-
if isinstance(totals, dict):
|
|
688
|
-
usage = {"input": 0, "output": 0, "rounds": 0}
|
|
689
|
-
with totals.get("_lock", threading.Lock()):
|
|
690
|
-
for key in ("input", "output", "rounds"):
|
|
691
|
-
value = totals.get(key)
|
|
692
|
-
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
693
|
-
usage[key] = int(value)
|
|
694
657
|
view.emit_result(
|
|
695
|
-
answer,
|
|
658
|
+
answer,
|
|
659
|
+
errors=errors,
|
|
660
|
+
usage=usage_snapshot(session),
|
|
661
|
+
model=session.model,
|
|
662
|
+
cancelled=cancelled,
|
|
696
663
|
)
|
|
697
664
|
|
|
698
665
|
if restore is not None and not restore_session(controller, restore, view.err):
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Shared session-restore logic for the TUI's /restore and headless --restore.
|
|
2
|
+
|
|
3
|
+
Both front ends resolve a saved session the same way (path, title
|
|
4
|
+
substring, or latest), parse it the same way, and apply it to a
|
|
5
|
+
``Controller`` the same way: repoint the session store, restore the
|
|
6
|
+
saved agent and model, and replace the conversation history. Only the
|
|
7
|
+
reporting differs, so callers pass a ``report(level, message)``
|
|
8
|
+
callback ("ok" for confirmations, "warning" for problems).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from ..core.models import Message
|
|
19
|
+
from ..io import persistence
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from .controller import Controller
|
|
23
|
+
|
|
24
|
+
AGENT_KEY = "python-agent-harness--agent"
|
|
25
|
+
MODEL_KEY = "python-agent-harness--model"
|
|
26
|
+
PROJECT_KEY = "python-agent-harness--project-dir"
|
|
27
|
+
ROUND_TIMES_KEY = "python-agent-harness--round-times"
|
|
28
|
+
|
|
29
|
+
Report = Callable[[str, str], None]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class SavedSession:
|
|
34
|
+
"""A parsed session file: its path, metadata and conversation."""
|
|
35
|
+
|
|
36
|
+
path: str
|
|
37
|
+
meta: dict[str, str] = field(default_factory=dict)
|
|
38
|
+
messages: list[Message] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def round_times(self) -> list[float]:
|
|
42
|
+
"""Persisted round timestamps; [] when absent or malformed."""
|
|
43
|
+
raw = self.meta.get(ROUND_TIMES_KEY)
|
|
44
|
+
if not raw:
|
|
45
|
+
return []
|
|
46
|
+
try:
|
|
47
|
+
return [float(x) for x in raw.split()]
|
|
48
|
+
except ValueError:
|
|
49
|
+
return []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_session_path(spec: str) -> str | None:
|
|
53
|
+
"""Map SPEC (empty/"latest"/"--latest", a file path, or a title
|
|
54
|
+
substring) to a session file path, or None when nothing matches.
|
|
55
|
+
|
|
56
|
+
The result is not guaranteed to exist: callers check ``isfile``.
|
|
57
|
+
"""
|
|
58
|
+
if not spec or spec in ("--latest", "latest"):
|
|
59
|
+
return persistence.SessionPersistence.latest_session()
|
|
60
|
+
expanded = os.path.expanduser(spec)
|
|
61
|
+
if os.path.isfile(expanded):
|
|
62
|
+
return expanded
|
|
63
|
+
return persistence.find_session_by_title(spec)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_saved_session(path: str) -> SavedSession:
|
|
67
|
+
"""Read and parse the session file at PATH. Raises OSError.
|
|
68
|
+
|
|
69
|
+
surrogateescape mirrors ``SessionPersistence.save``: the saved body
|
|
70
|
+
may carry lone surrogates (non-UTF-8 file content), which a strict
|
|
71
|
+
decode would reject.
|
|
72
|
+
"""
|
|
73
|
+
with open(path, encoding="utf-8", errors="surrogateescape") as fh:
|
|
74
|
+
text = fh.read()
|
|
75
|
+
store = persistence.SessionPersistence
|
|
76
|
+
meta = store.parse_metadata(text)
|
|
77
|
+
messages = persistence.parse_saved_body(store.strip_metadata(text))
|
|
78
|
+
return SavedSession(path=path, meta=meta, messages=messages)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def restore_agent(controller: Controller, agent: str | None, report: Report) -> None:
|
|
82
|
+
"""Switch to the saved AGENT; fall back to the default agent (so no
|
|
83
|
+
stale tool exclusions linger) when it no longer exists."""
|
|
84
|
+
if not agent:
|
|
85
|
+
return
|
|
86
|
+
try:
|
|
87
|
+
success, msg = controller.switch_agent(agent)
|
|
88
|
+
if success:
|
|
89
|
+
report("ok", msg)
|
|
90
|
+
else:
|
|
91
|
+
controller.switch_agent("default")
|
|
92
|
+
report("warning", msg)
|
|
93
|
+
except Exception as e: # noqa: BLE001 - a bad agent must not abort the restore
|
|
94
|
+
report("warning", f"could not restore agent: {e}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def restore_model(controller: Controller, model: str | None, report: Report) -> None:
|
|
98
|
+
"""Switch back to the saved raw MODEL name through a profile.
|
|
99
|
+
|
|
100
|
+
Profile switching also fixes base_url/api_key/temperature, which a
|
|
101
|
+
bare model-name change would leave pointing at the wrong endpoint.
|
|
102
|
+
Order: the "default" pseudo-profile when MODEL is the session-start
|
|
103
|
+
model, else MODEL as a profile name, else a profile whose model
|
|
104
|
+
matches. Without a match the current model stays active.
|
|
105
|
+
"""
|
|
106
|
+
if not model or model == controller.model:
|
|
107
|
+
return
|
|
108
|
+
try:
|
|
109
|
+
if model == controller.llm_settings.get("model"):
|
|
110
|
+
_, msg = controller.switch_model("default")
|
|
111
|
+
report("ok", msg)
|
|
112
|
+
return
|
|
113
|
+
success, msg = controller.switch_model(model)
|
|
114
|
+
if not success:
|
|
115
|
+
profile = next(
|
|
116
|
+
(name for name, p in controller.model_profiles.items() if p.get("model") == model),
|
|
117
|
+
None,
|
|
118
|
+
)
|
|
119
|
+
if profile:
|
|
120
|
+
success, msg = controller.switch_model(profile)
|
|
121
|
+
if success:
|
|
122
|
+
report("ok", msg)
|
|
123
|
+
else:
|
|
124
|
+
report("warning", f"model {model!r} has no matching profile, keeping current model")
|
|
125
|
+
except Exception as e: # noqa: BLE001 - a bad model must not abort the restore
|
|
126
|
+
report("warning", f"could not restore model: {e}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def apply_saved_session(controller: Controller, saved: SavedSession, report: Report) -> None:
|
|
130
|
+
"""Make SAVED the controller's current session.
|
|
131
|
+
|
|
132
|
+
The conversation history is replaced in BOTH places the runtime
|
|
133
|
+
reads it: ``conversation_history`` (what the next run sends) and
|
|
134
|
+
``last_messages`` (what the views render). ``run_generation`` is
|
|
135
|
+
bumped so a worker still winding down from a cancelled run cannot
|
|
136
|
+
clobber the restored history with its salvaged one.
|
|
137
|
+
"""
|
|
138
|
+
store = controller.store
|
|
139
|
+
store.round_times = saved.round_times
|
|
140
|
+
store.file_path = saved.path
|
|
141
|
+
title = persistence.title_from_filename(saved.path)
|
|
142
|
+
if title:
|
|
143
|
+
store.title = title
|
|
144
|
+
restore_agent(controller, saved.meta.get(AGENT_KEY), report)
|
|
145
|
+
restore_model(controller, saved.meta.get(MODEL_KEY), report)
|
|
146
|
+
controller.run_generation += 1
|
|
147
|
+
controller.conversation_history = list(saved.messages)
|
|
148
|
+
controller.last_messages = list(saved.messages)
|
|
149
|
+
controller.clear_todos()
|
{python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/entry/server.py
RENAMED
|
@@ -130,7 +130,14 @@ from typing import Any, TextIO
|
|
|
130
130
|
from ..core.models import api_safe
|
|
131
131
|
from ..session.session import Session
|
|
132
132
|
from .controller import Controller
|
|
133
|
-
from .headless import
|
|
133
|
+
from .headless import (
|
|
134
|
+
PROTOCOL_VERSION,
|
|
135
|
+
JsonlView,
|
|
136
|
+
_cancelled_now,
|
|
137
|
+
_restorable_handler,
|
|
138
|
+
final_answer_text,
|
|
139
|
+
usage_snapshot,
|
|
140
|
+
)
|
|
134
141
|
|
|
135
142
|
DEFAULT_ANSWER_TIMEOUT = 0.0 # wait forever for a host answer
|
|
136
143
|
|
|
@@ -581,8 +588,7 @@ class AgentServer:
|
|
|
581
588
|
# -- run execution ------------------------------------------------------------
|
|
582
589
|
|
|
583
590
|
def _cancelled_now(self) -> bool:
|
|
584
|
-
|
|
585
|
-
return event is not None and event.is_set() is True
|
|
591
|
+
return _cancelled_now(self.session)
|
|
586
592
|
|
|
587
593
|
def _final_answer(self) -> str:
|
|
588
594
|
"""The run's final assistant answer, TUI-filtered (headless shape).
|
|
@@ -595,16 +601,7 @@ class AgentServer:
|
|
|
595
601
|
|
|
596
602
|
def _usage_snapshot(self) -> dict[str, Any] | None:
|
|
597
603
|
"""Cumulative input/output tokens and rounds of the current run."""
|
|
598
|
-
|
|
599
|
-
if not isinstance(totals, dict):
|
|
600
|
-
return None
|
|
601
|
-
usage: dict[str, Any] = {"input": 0, "output": 0, "rounds": 0}
|
|
602
|
-
with totals.get("_lock", threading.Lock()):
|
|
603
|
-
for key in ("input", "output", "rounds"):
|
|
604
|
-
value = totals.get(key)
|
|
605
|
-
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
606
|
-
usage[key] = int(value)
|
|
607
|
-
return usage
|
|
604
|
+
return usage_snapshot(self.session)
|
|
608
605
|
|
|
609
606
|
def _execute_run(self, prompt: str, run_id: str) -> None:
|
|
610
607
|
"""Run one prompt to completion, streaming lines as they happen.
|
{python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/session/config.py
RENAMED
|
@@ -53,6 +53,14 @@ CALIBRATION_MAX = 3.0
|
|
|
53
53
|
SESSION_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
|
54
54
|
SESSION_SUBDIR = "python-agent-harness/sessions"
|
|
55
55
|
AUTO_SAVE_SESSION = True
|
|
56
|
+
# auto_save is called after every round; the full-conversation rewrite is
|
|
57
|
+
# debounced so a long run does not rewrite the whole file each round.
|
|
58
|
+
# A save is skipped while fewer than AUTO_SAVE_MIN_INTERVAL seconds have
|
|
59
|
+
# passed since the last one AND the message count has not grown by
|
|
60
|
+
# AUTO_SAVE_MIN_GROWTH_MSGS. flush_auto_save() bypasses the debounce
|
|
61
|
+
# (run end, cancel, compaction).
|
|
62
|
+
AUTO_SAVE_MIN_INTERVAL = 2.0
|
|
63
|
+
AUTO_SAVE_MIN_GROWTH_MSGS = 8
|
|
56
64
|
|
|
57
65
|
# ---- LLM interaction logs ---------------------------------------------------
|
|
58
66
|
LLM_LOG_ENABLED = False
|
|
@@ -111,6 +119,36 @@ BASH_TIMEOUT_SILENCE: float | None = 120.0
|
|
|
111
119
|
# than this regardless of output. None disables the cap (default).
|
|
112
120
|
BASH_TIMEOUT_MAX: float | None = None
|
|
113
121
|
|
|
122
|
+
# Environment variables removed from every Bash command's environment
|
|
123
|
+
# (case-insensitive fnmatch patterns over the variable NAME). The child
|
|
124
|
+
# would otherwise inherit the harness's whole environment, and a single
|
|
125
|
+
# `printenv` would put API keys and cloud credentials into the model's
|
|
126
|
+
# context (and the provider's logs). Names listed in the config file's
|
|
127
|
+
# ``bash.env_passthrough`` are kept regardless, and so are variables whose
|
|
128
|
+
# value is an existing absolute path (a pointer to a credentials file, e.g.
|
|
129
|
+
# GOOGLE_APPLICATION_CREDENTIALS, is not itself a secret) or a trivial
|
|
130
|
+
# flag (0/1/true/false/...). A denylist, not an allowlist: commands keep
|
|
131
|
+
# PATH, toolchain, proxy and SSH-agent settings.
|
|
132
|
+
BASH_ENV_SECRET_PATTERNS: tuple[str, ...] = (
|
|
133
|
+
"*API_KEY*",
|
|
134
|
+
"*APIKEY*",
|
|
135
|
+
"*_KEY",
|
|
136
|
+
"*PRIVATE_KEY*",
|
|
137
|
+
"*SECRET*",
|
|
138
|
+
"*TOKEN",
|
|
139
|
+
"*PASSWORD*",
|
|
140
|
+
"*PASSWD*",
|
|
141
|
+
"*_PASS",
|
|
142
|
+
"*_PWD",
|
|
143
|
+
"*_PAT",
|
|
144
|
+
"*CREDENTIAL*",
|
|
145
|
+
"*COOKIE*",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Names that match a secret pattern above but never hold a secret
|
|
149
|
+
# (public keys are public by definition); checked first.
|
|
150
|
+
BASH_ENV_NOT_SECRET_PATTERNS: tuple[str, ...] = ("*PUBLIC_KEY*", "*PUBKEY*")
|
|
151
|
+
|
|
114
152
|
# ---- LLM client ----------------------------------------------------------------
|
|
115
153
|
DEFAULT_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
|
116
154
|
DEFAULT_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5-mini")
|
|
@@ -252,6 +290,10 @@ CONFIG_TEMPLATE = """\
|
|
|
252
290
|
"timeout": null
|
|
253
291
|
}},
|
|
254
292
|
"default_agent": null,
|
|
293
|
+
"bash": {{
|
|
294
|
+
"_comment": "Secret-looking environment variables (names matching *API_KEY*, *SECRET*, *TOKEN, *PASSWORD*, ... see BASH_ENV_SECRET_PATTERNS in config.py) are removed from Bash commands' environment so `printenv` cannot leak them to the model (variables holding an existing file path are kept). List variable names here to pass specific ones through anyway, e.g. [\\"GH_TOKEN\\"] for `gh`.",
|
|
295
|
+
"env_passthrough": []
|
|
296
|
+
}},
|
|
255
297
|
"paths": {{
|
|
256
298
|
"_comment": "Optional overrides for context and skill directories. Absolute paths or ~ expansion supported.",
|
|
257
299
|
"context_path": null,
|
|
@@ -497,6 +539,27 @@ def load_paths_config(path: str | os.PathLike | None = None) -> dict:
|
|
|
497
539
|
return settings
|
|
498
540
|
|
|
499
541
|
|
|
542
|
+
def load_bash_env_passthrough(path: str | os.PathLike | None = None) -> frozenset[str]:
|
|
543
|
+
"""Names from ``bash.env_passthrough``: secret-looking environment
|
|
544
|
+
variables the user explicitly lets Bash commands see (e.g. GH_TOKEN
|
|
545
|
+
for `gh`).
|
|
546
|
+
|
|
547
|
+
Fails closed: a missing/broken file or a malformed section yields
|
|
548
|
+
an empty set, so secrets stay stripped.
|
|
549
|
+
"""
|
|
550
|
+
try:
|
|
551
|
+
data = _read_config(path)
|
|
552
|
+
except ValueError:
|
|
553
|
+
return frozenset()
|
|
554
|
+
section = data.get("bash")
|
|
555
|
+
if not isinstance(section, dict):
|
|
556
|
+
return frozenset()
|
|
557
|
+
names = section.get("env_passthrough")
|
|
558
|
+
if not isinstance(names, list):
|
|
559
|
+
return frozenset()
|
|
560
|
+
return frozenset(n.strip().upper() for n in names if isinstance(n, str) and n.strip())
|
|
561
|
+
|
|
562
|
+
|
|
500
563
|
def load_mcp_config(path: str | os.PathLike | None = None) -> MCPConfig:
|
|
501
564
|
"""Load MCP server settings from the config file's ``mcp`` object.
|
|
502
565
|
|
|
@@ -195,6 +195,10 @@ class Session:
|
|
|
195
195
|
self.todos: list[dict] = []
|
|
196
196
|
self.pending_user_prompts: list[str] = []
|
|
197
197
|
self._save_error: str | None = None
|
|
198
|
+
# auto_save debounce state (see config.AUTO_SAVE_*): the last
|
|
199
|
+
# successful save's wall time and conversation length
|
|
200
|
+
self._last_save_at: float | None = None
|
|
201
|
+
self._last_save_len: int = 0
|
|
198
202
|
self.last_messages: list = []
|
|
199
203
|
self.cancel_event = threading.Event()
|
|
200
204
|
# Named LLM profiles for runtime switching via /model command
|
|
@@ -680,6 +684,39 @@ class Session:
|
|
|
680
684
|
def auto_save(self, messages: list, system: str | None) -> None:
|
|
681
685
|
if not config.AUTO_SAVE_SESSION:
|
|
682
686
|
return
|
|
687
|
+
# Debounce the full-conversation rewrite: auto_save runs after
|
|
688
|
+
# every round, but the file only needs refreshing when enough
|
|
689
|
+
# time has passed AND the conversation grew. The growth check
|
|
690
|
+
# is on the message count (cheap) before the expensive
|
|
691
|
+
# _conversation_text serialization.
|
|
692
|
+
now = time.monotonic()
|
|
693
|
+
# getattr: test doubles built via __new__ skip __init__ and never
|
|
694
|
+
# set the debounce state; treat a missing state as "never saved".
|
|
695
|
+
last = getattr(self, "_last_save_at", None)
|
|
696
|
+
if last is not None:
|
|
697
|
+
elapsed = now - last
|
|
698
|
+
grew = (
|
|
699
|
+
len(messages) - getattr(self, "_last_save_len", 0)
|
|
700
|
+
>= config.AUTO_SAVE_MIN_GROWTH_MSGS
|
|
701
|
+
)
|
|
702
|
+
# skip while inside the min interval and the conversation has
|
|
703
|
+
# not grown enough; a big round (grew) or the interval
|
|
704
|
+
# elapsing forces a save
|
|
705
|
+
if elapsed < config.AUTO_SAVE_MIN_INTERVAL and not grew:
|
|
706
|
+
return
|
|
707
|
+
self._do_save(messages)
|
|
708
|
+
|
|
709
|
+
def flush_auto_save(self, messages: list) -> None:
|
|
710
|
+
"""Save immediately, bypassing the auto_save debounce.
|
|
711
|
+
|
|
712
|
+
Called at run end / cancel / compaction, where the conversation
|
|
713
|
+
must reach the session file regardless of the debounce window.
|
|
714
|
+
"""
|
|
715
|
+
if not config.AUTO_SAVE_SESSION:
|
|
716
|
+
return
|
|
717
|
+
self._do_save(messages)
|
|
718
|
+
|
|
719
|
+
def _do_save(self, messages: list) -> None:
|
|
683
720
|
text = self._conversation_text(messages)
|
|
684
721
|
# retry once: transient failures (NFS hiccup, brief lock) clear
|
|
685
722
|
# on the second attempt; permanent ones (disk full, read-only)
|
|
@@ -689,6 +726,8 @@ class Session:
|
|
|
689
726
|
try:
|
|
690
727
|
self.store.save(text)
|
|
691
728
|
self._save_error = None
|
|
729
|
+
self._last_save_at = time.monotonic()
|
|
730
|
+
self._last_save_len = len(messages)
|
|
692
731
|
return
|
|
693
732
|
except (OSError, UnicodeError) as e:
|
|
694
733
|
# UnicodeError too: a save failure must never escape into
|
|
@@ -983,7 +1022,7 @@ class Session:
|
|
|
983
1022
|
# preserved verbatim after the frame, so the model keeps
|
|
984
1023
|
# the actual requests.
|
|
985
1024
|
self.last_messages = compacted_messages(summary, user_prompt_texts(messages))
|
|
986
|
-
self.
|
|
1025
|
+
self.flush_auto_save(self.last_messages)
|
|
987
1026
|
self.notify("compact")
|
|
988
1027
|
return True, "Session compacted successfully."
|
|
989
1028
|
except Exception as e: # noqa: BLE001 - compaction failure is non-fatal
|
|
@@ -1019,7 +1058,7 @@ class Session:
|
|
|
1019
1058
|
if not summary:
|
|
1020
1059
|
return "Summary failed: empty response."
|
|
1021
1060
|
self.last_messages.append(Msg(role="assistant", content=summary))
|
|
1022
|
-
self.
|
|
1061
|
+
self.flush_auto_save(self.last_messages)
|
|
1023
1062
|
return "Summary appended."
|
|
1024
1063
|
|
|
1025
1064
|
|