sigit-code 1.3.1__tar.gz → 1.3.2__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.
- {sigit_code-1.3.1 → sigit_code-1.3.2}/CHANGELOG.md +17 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/CLAUDE.md +36 -12
- {sigit_code-1.3.1 → sigit_code-1.3.2}/Cargo.lock +1 -1
- {sigit_code-1.3.1 → sigit_code-1.3.2}/Cargo.toml +1 -1
- {sigit_code-1.3.1 → sigit_code-1.3.2}/PKG-INFO +1 -1
- {sigit_code-1.3.1 → sigit_code-1.3.2}/src/backend.rs +394 -6
- {sigit_code-1.3.1 → sigit_code-1.3.2}/src/chat.rs +300 -6
- {sigit_code-1.3.1 → sigit_code-1.3.2}/src/main.rs +391 -26
- sigit_code-1.3.2/src/permissions.rs +337 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/src/provider.rs +5 -5
- sigit_code-1.3.2/src/session_store.rs +167 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/src/settings.rs +134 -5
- {sigit_code-1.3.1 → sigit_code-1.3.2}/src/tools.rs +1224 -25
- sigit_code-1.3.2/tests/acp_permissions.rs +353 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/.agents/AGENTS.md +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/.agents/skills/agent-client-protocol/SKILL.md +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/.agents/skills/ai-assisted-coding/SKILL.md +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/.agents/skills/branding/SKILL.md +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/.agents/skills/sigit-code-release/SKILL.md +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/.agents/skills/tool-calling/SKILL.md +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/.claude/skills/run-sigit/SKILL.md +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/.claude/skills/run-sigit/driver.mjs +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/.claude/skills/run-sigit/tui-smoke.sh +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/.github/workflows/ci.yml +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/.github/workflows/release-crates.yml +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/.github/workflows/release-github.yml +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/.github/workflows/release-homebrew.yml +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/.github/workflows/release-npm.yml +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/.github/workflows/release-pypi.yml +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/.gitignore +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/.nvmrc +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/LICENSE +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/README.md +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/examples/skills/README.md +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/examples/skills/commit-message/SKILL.md +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/npm/README.md.tmpl +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/npm/package-main.json.tmpl +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/npm/package.json.tmpl +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/npm/scripts/render-main-package.cjs +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/npm/scripts/render-platform-package.cjs +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/npm/sigit/.gitignore +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/npm/sigit/README.md +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/npm/sigit/package.json +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/npm/sigit/src/index.ts +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/npm/sigit/tsconfig.json +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/pypi/README.md +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/pypi/pyproject.toml +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/pyproject.toml +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/rust-toolchain.toml +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/src/account.rs +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/src/credentials.rs +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/src/instructions.rs +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/src/mcp.rs +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/src/models.rs +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/src/setup.rs +0 -0
- {sigit_code-1.3.1 → sigit_code-1.3.2}/src/skills.rs +0 -0
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.3.2
|
|
4
|
+
|
|
5
|
+
Adds a tool permission system with plan mode, durable sessions with context
|
|
6
|
+
compaction, background command execution, a subagent research tool, and commit
|
|
7
|
+
co-author attribution.
|
|
8
|
+
|
|
9
|
+
### What changed
|
|
10
|
+
|
|
11
|
+
- Every tool call now passes a permission policy before executing. Read-only tools always run; mutating tools (and all MCP or unknown tools) are governed by, in order: plan mode, session grants, per-tool overrides, and a default mode from `[permissions]` in `settings.toml` (`allow`/`ask`/`deny`, default `ask`). On `ask`, editors get a native ACP permission dialog (allow once / allow for this session / deny) and the TUI pauses on a y/a/n prompt showing the tool and its arguments. `SIGIT_PERMISSIONS` overrides the default mode for headless runs and clients without permission support
|
|
12
|
+
- New `/plan [on|off]` command: plan mode blocks mutating tools and asks the model to present a plan while research tools keep working. `/permissions` prints the effective policy
|
|
13
|
+
- Sessions are durable: conversation history is saved per session under `~/.config/sigit/sessions/` after every turn. ACP `session/load` actually restores it, the TUI gets `/resume`, and `/clear` deletes the saved file
|
|
14
|
+
- Context compaction: `/compact` compresses the conversation on demand, and the agent compacts automatically once the history approaches a 24k-token budget, summarizing older turns and keeping the recent ones. The tool-round cap rises from 10 to 24 now that long sessions have a defense other than the cap
|
|
15
|
+
- `run_command` can run work in the background: pass `run_in_background` and the tool returns a task id immediately, so builds, test suites, and dev servers are no longer killed by the 120 second foreground timeout. Poll with the new `command_output` tool (read-only, never prompts) and stop with `kill_command`
|
|
16
|
+
- New `task` tool: delegate research to a fresh subagent conversation that only gets the read-only tools and returns its final answer, keeping the main context small. Available on OpenAI-compatible backends; on-device returns a clear fallback until onde supports a second context
|
|
17
|
+
- Commits created by the agent are co-authored: commit messages end with `Co-Authored-By: siGit Code <sigit@sigit.si>` (the [sigitc](https://github.com/sigitc) account), which GitHub renders next to the human author. If the model forgets the trailer, siGit amends it in, never rewriting commits that already exist on a remote
|
|
18
|
+
- ACP mode now honors the `OPENAI_BASE_URL`/`OPENAI_API_KEY` provider override at startup, matching the interactive client
|
|
19
|
+
|
|
3
20
|
## 1.3.1
|
|
4
21
|
|
|
5
22
|
Adds [Model Context Protocol](https://modelcontextprotocol.io) (MCP) client
|
|
@@ -19,14 +19,22 @@ Before the TTY/ACP split, `main` also dispatches the account subcommands `sigit
|
|
|
19
19
|
|
|
20
20
|
## Working in this repo
|
|
21
21
|
|
|
22
|
-
**IMPORTANT — branch naming:**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
**IMPORTANT — branch naming:** Prefix every working branch with `feature/` (new functionality)
|
|
23
|
+
or `fix/` (bug fixes) — never a tool- or agent-name prefix like `claude/`. Name the branch after
|
|
24
|
+
the *changes it contains*, as a short, descriptive, kebab-case slug that is self-explanatory
|
|
25
|
+
from the name alone (e.g. `feature/tool-permission-system`, `fix/glob-mtime-sort`), never after
|
|
26
|
+
a task, ticket, or session id (not `feature/task-q003hm`).
|
|
26
27
|
|
|
27
28
|
**IMPORTANT — pull request target:** Always open pull requests against the `development` branch,
|
|
28
29
|
never `main`. `main` is release-only; `development` is where day-to-day work integrates.
|
|
29
30
|
|
|
31
|
+
**IMPORTANT — branch off `development`:** Start every working branch from the latest
|
|
32
|
+
`origin/development`. Exception: when new work *depends on* a feature branch that has not merged
|
|
33
|
+
yet (e.g. it builds on tools or APIs that branch introduces), it may be stacked on top of that
|
|
34
|
+
branch instead. When stacking: merge the base PR into `development` first, then rebase the
|
|
35
|
+
stacked branch onto `development` before opening its pull request, so each PR shows only its own
|
|
36
|
+
commits.
|
|
37
|
+
|
|
30
38
|
**IMPORTANT — run CI before pushing:** Run the full CI gate locally and confirm it is green
|
|
31
39
|
*before* pushing a branch or opening a pull request — never push work that fails these:
|
|
32
40
|
|
|
@@ -55,10 +63,10 @@ Run a single test: `cargo test <test_name>`.
|
|
|
55
63
|
|
|
56
64
|
## Critical platform constraint: `#[cfg(unix)]` dead code
|
|
57
65
|
|
|
58
|
-
The interactive client
|
|
59
|
-
(`provider.rs`) are
|
|
60
|
-
|
|
61
|
-
|
|
66
|
+
The interactive client is `#[cfg(unix)]`-only. The `InferenceBackend` seam (`backend.rs`) and
|
|
67
|
+
provider resolution (`provider.rs`) are consumed by both the interactive client and the ACP
|
|
68
|
+
server, but several of their items are reached only through the Unix-only interactive paths, so
|
|
69
|
+
the dead-code lint is suppressed *on non-Unix targets only*.
|
|
62
70
|
|
|
63
71
|
Consequence: code can pass clippy on macOS/Linux but fail on the Windows target (or vice versa).
|
|
64
72
|
When touching `backend.rs`, `provider.rs`, or the interactive path, keep the `cfg` gates intact —
|
|
@@ -83,7 +91,10 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
|
|
|
83
91
|
- **`src/tools.rs`** — agent tool schemas + execution: `read_file`, `create_directory`,
|
|
84
92
|
`list_directory`, `search_files`, `glob`, `read_website`, `create_file`, `edit_file`,
|
|
85
93
|
`multi_edit`, `delete_file`, `run_command`, `write_todos`, `remember`. Add a tool in both the
|
|
86
|
-
spec list (`all_tools`) and the execute `match` (`execute_tool`).
|
|
94
|
+
spec list (`all_tools`) and the execute `match` (`execute_tool`). `run_command` also enforces
|
|
95
|
+
commit attribution: when a command creates a new commit that lacks the
|
|
96
|
+
`Co-Authored-By: siGit Code` trailer (`COMMIT_CO_AUTHOR_TRAILER`), it amends the trailer in —
|
|
97
|
+
unless the commit already exists on a remote, which is never rewritten.
|
|
87
98
|
- **`src/skills.rs`** — [Agent Skills](https://agentskills.io) support. Discovers skill
|
|
88
99
|
folders (each with a `SKILL.md`: YAML frontmatter `name` + `description`, then Markdown
|
|
89
100
|
instructions) from `.sigit/skills/` and `.claude/skills/` in the cwd, `$SIGIT_CONFIG_DIR/skills/`,
|
|
@@ -102,6 +113,17 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
|
|
|
102
113
|
official server (`<cloud>/mcp`, default `https://sigit.si/api/v1/mcp`) is baked in and authed with the
|
|
103
114
|
cloud session token; extra servers live in `mcp.toml` (global `$SIGIT_CONFIG_DIR/mcp.toml` and
|
|
104
115
|
project-local `.sigit/mcp.toml`). stdio transport is not supported.
|
|
116
|
+
- **`src/permissions.rs`** — tool permission policy. Every tool call passes through
|
|
117
|
+
`decision_for` before executing: read-only tools always run; mutating tools (and all
|
|
118
|
+
`mcp__*`/unknown tools) are governed by, in order: per-session plan mode (`/plan` — deny all
|
|
119
|
+
mutating tools with a present-a-plan message), session "always allow" grants, per-tool
|
|
120
|
+
overrides and the default mode from `[permissions]` in `settings.toml` (`allow`/`ask`/`deny`,
|
|
121
|
+
default `ask`; `SIGIT_PERMISSIONS` env overrides the default). On `ask`, the ACP path sends
|
|
122
|
+
`session/request_permission` (allow once / allow for session / deny) and the TUI pauses the
|
|
123
|
+
inference task on a y/a/n prompt. Note: ACP turn-affecting handlers run in `cx.spawn`ed tasks
|
|
124
|
+
serialized by `SiGitAgent::turn_lock` so the dispatch loop can route the client's permission
|
|
125
|
+
answer mid-turn — don't move them back inline, and don't await client requests from inline
|
|
126
|
+
handlers (deadlock).
|
|
105
127
|
- **`src/instructions.rs`** — project instruction files, the always-on counterpart to skills.
|
|
106
128
|
Reads `AGENTS.md` (the cross-tool [agents.md](https://agents.md) standard) and `CLAUDE.md`,
|
|
107
129
|
walking from the session cwd up to the repo root (nearest ancestor with `.git`, never above it),
|
|
@@ -120,8 +142,8 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
|
|
|
120
142
|
- **`src/models.rs`** — model-picker types shared across platforms.
|
|
121
143
|
|
|
122
144
|
Slash commands (`/help`, `/models`, `/skills`, `/mcp`, `/login`, `/logout`, `/whoami`, `/reload`,
|
|
123
|
-
`/clear`, `/status`) are advertised via `advertise_commands` in `main.rs`
|
|
124
|
-
TUI and ACP sessions.
|
|
145
|
+
`/plan`, `/permissions`, `/clear`, `/status`) are advertised via `advertise_commands` in `main.rs`
|
|
146
|
+
and handled in both the TUI and ACP sessions.
|
|
125
147
|
|
|
126
148
|
## Model cache (macOS)
|
|
127
149
|
|
|
@@ -142,7 +164,9 @@ verbosity with `RUST_LOG`.
|
|
|
142
164
|
`OPENAI_BASE_URL` / `OPENAI_API_KEY` (provider override), `SIGIT_API_URL` (account API base,
|
|
143
165
|
default `https://sigit.si`), `SIGIT_CLOUD_URL`, `SIGIT_CONFIG_DIR` (default `~/.config/sigit`),
|
|
144
166
|
`SIGIT_MODEL`, `SIGIT_MCP` (`off` disables MCP), `SIGIT_MCP_OFFICIAL` (`off` drops the baked-in
|
|
145
|
-
server), `
|
|
167
|
+
server), `SIGIT_PERMISSIONS` (`allow`/`ask`/`deny` — overrides the default permission mode for
|
|
168
|
+
mutating tools; the escape hatch for clients without permission-request support),
|
|
169
|
+
`HF_HOME` / `HF_HUB_CACHE`, `RUST_LOG`.
|
|
146
170
|
|
|
147
171
|
## Releasing
|
|
148
172
|
|
|
@@ -11,17 +11,17 @@
|
|
|
11
11
|
//! The trait exposes neither `onde` nor OpenAI types, so the loop does not depend
|
|
12
12
|
//! on a specific backend.
|
|
13
13
|
//!
|
|
14
|
-
//! The
|
|
15
|
-
//!
|
|
16
|
-
//!
|
|
17
|
-
//!
|
|
18
|
-
//!
|
|
14
|
+
//! The seam is consumed by both surfaces: the interactive client (`#[cfg(unix)]`,
|
|
15
|
+
//! see `run_interactive` in `main.rs` and `mod tui` in `chat.rs`) and the ACP
|
|
16
|
+
//! server's prompt loop. Some items are still reached only through the
|
|
17
|
+
//! Unix-only interactive paths, so the dead-code lint stays suppressed on
|
|
18
|
+
//! non-Unix targets only — Unix builds keep full coverage.
|
|
19
19
|
#![cfg_attr(not(unix), allow(dead_code))]
|
|
20
20
|
|
|
21
21
|
use std::sync::Arc;
|
|
22
22
|
|
|
23
23
|
use async_trait::async_trait;
|
|
24
|
-
use onde::inference::{ChatEngine, ToolDefinition};
|
|
24
|
+
use onde::inference::{ChatEngine, ChatMessage, ChatRole, ToolDefinition};
|
|
25
25
|
use serde::Deserialize;
|
|
26
26
|
use tokio::sync::Mutex;
|
|
27
27
|
|
|
@@ -62,6 +62,30 @@ pub struct TurnResult {
|
|
|
62
62
|
/// Backend errors are plain strings. Callers map them to ACP errors.
|
|
63
63
|
pub type BackendError = String;
|
|
64
64
|
|
|
65
|
+
/// Rough context budget for a conversation, in estimated tokens (see
|
|
66
|
+
/// [`estimate_tokens`]). When a snapshot exceeds this, the agent loops compact
|
|
67
|
+
/// history before the next tool round.
|
|
68
|
+
pub const DEFAULT_CONTEXT_TOKEN_BUDGET: usize = 24_000;
|
|
69
|
+
|
|
70
|
+
/// How many trailing messages survive a compaction verbatim (the rest are
|
|
71
|
+
/// folded into the summary).
|
|
72
|
+
pub const COMPACT_KEEP_LAST: usize = 6;
|
|
73
|
+
|
|
74
|
+
/// The summarization request sent to the model when compacting history.
|
|
75
|
+
const SUMMARIZE_PROMPT: &str = "Summarize this coding session so far: decisions made, \
|
|
76
|
+
files touched, current state, open items. Be concise and factual.";
|
|
77
|
+
|
|
78
|
+
/// Crude token estimate for a history snapshot: serialized characters / 4.
|
|
79
|
+
/// Deliberately model-agnostic — it only needs to be in the right ballpark to
|
|
80
|
+
/// decide when compaction is worth an extra inference round.
|
|
81
|
+
pub fn estimate_tokens(history: &[serde_json::Value]) -> usize {
|
|
82
|
+
let chars: usize = history
|
|
83
|
+
.iter()
|
|
84
|
+
.map(|message| message.to_string().chars().count())
|
|
85
|
+
.sum();
|
|
86
|
+
chars / 4
|
|
87
|
+
}
|
|
88
|
+
|
|
65
89
|
/// A sink for streaming assistant text deltas to the UI as they are produced.
|
|
66
90
|
///
|
|
67
91
|
/// When a caller passes `Some(sink)`, a streaming-capable backend forwards each
|
|
@@ -98,10 +122,35 @@ pub trait InferenceBackend: Send + Sync {
|
|
|
98
122
|
sink: Option<&TokenSink>,
|
|
99
123
|
) -> Result<TurnResult, BackendError>;
|
|
100
124
|
|
|
125
|
+
/// Record tool results in the conversation history *without* asking the
|
|
126
|
+
/// model to continue the turn. Used when a turn is abandoned mid-round
|
|
127
|
+
/// (the user cancelled at the permission gate): by then the assistant
|
|
128
|
+
/// message carrying the tool calls is already in history, and leaving them
|
|
129
|
+
/// unanswered makes strict OpenAI-compatible endpoints reject every later
|
|
130
|
+
/// request in the session.
|
|
131
|
+
async fn record_cancelled_tool_results(&self, results: Vec<ToolResult>);
|
|
132
|
+
|
|
101
133
|
/// Whether inference runs over the network (a configured provider) rather
|
|
102
134
|
/// than on-device. Drives UI labelling so the displayed model can't claim a
|
|
103
135
|
/// local model while requests actually go to the cloud.
|
|
104
136
|
fn is_remote(&self) -> bool;
|
|
137
|
+
|
|
138
|
+
/// A serializable snapshot of the conversation history, one JSON object per
|
|
139
|
+
/// message (`{"role": ..., "content": ...}` at minimum). The snapshot is
|
|
140
|
+
/// what the session store persists; it includes any seeded system message
|
|
141
|
+
/// so [`InferenceBackend::restore_history`] can replace state wholesale.
|
|
142
|
+
async fn history_snapshot(&self) -> Vec<serde_json::Value>;
|
|
143
|
+
|
|
144
|
+
/// Replace the conversation history with a previously saved snapshot.
|
|
145
|
+
/// Backends that cannot represent every entry (e.g. on-device history has
|
|
146
|
+
/// no tool-call structure) flatten what they can and drop the rest.
|
|
147
|
+
async fn restore_history(&self, history: Vec<serde_json::Value>);
|
|
148
|
+
|
|
149
|
+
/// Shrink the conversation history: summarize everything so far with one
|
|
150
|
+
/// extra (non-streaming) inference round, then rebuild history as
|
|
151
|
+
/// `[system message, summary, last keep_last non-system messages]`. On
|
|
152
|
+
/// error the original history is left in place.
|
|
153
|
+
async fn compact_history(&self, keep_last: usize) -> Result<(), BackendError>;
|
|
105
154
|
}
|
|
106
155
|
|
|
107
156
|
// ── Local backend (onde ChatEngine) ──────────────────────────────────────────────
|
|
@@ -195,9 +244,92 @@ impl InferenceBackend for LocalBackend {
|
|
|
195
244
|
Ok(onde_result_to_turn(result))
|
|
196
245
|
}
|
|
197
246
|
|
|
247
|
+
async fn record_cancelled_tool_results(&self, _results: Vec<ToolResult>) {
|
|
248
|
+
// onde's public API cannot append tool-result history entries without
|
|
249
|
+
// running another inference round, so the dangling tool call stays in
|
|
250
|
+
// its history. The chat template replays it as-is, which local models
|
|
251
|
+
// tolerate — worst case the model re-issues the call next turn.
|
|
252
|
+
}
|
|
253
|
+
|
|
198
254
|
fn is_remote(&self) -> bool {
|
|
199
255
|
false
|
|
200
256
|
}
|
|
257
|
+
|
|
258
|
+
async fn history_snapshot(&self) -> Vec<serde_json::Value> {
|
|
259
|
+
// onde's `history()` already flattens tool entries: assistant tool
|
|
260
|
+
// calls become plain assistant text and tool results are omitted, so
|
|
261
|
+
// the snapshot is lossy for tool-heavy turns (acceptable in this MVP).
|
|
262
|
+
self.engine
|
|
263
|
+
.history()
|
|
264
|
+
.await
|
|
265
|
+
.iter()
|
|
266
|
+
.map(|message| {
|
|
267
|
+
serde_json::json!({
|
|
268
|
+
"role": message.role.to_string(),
|
|
269
|
+
"content": message.content,
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
.collect()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async fn restore_history(&self, history: Vec<serde_json::Value>) {
|
|
276
|
+
self.engine.clear_history().await;
|
|
277
|
+
for entry in history {
|
|
278
|
+
let role = entry["role"].as_str().unwrap_or("");
|
|
279
|
+
let content = entry["content"].as_str().unwrap_or("").to_string();
|
|
280
|
+
// Tool-call-only assistant entries and empty tool results carry no
|
|
281
|
+
// text a plain chat history can replay; drop them.
|
|
282
|
+
if content.is_empty() && role != "user" && role != "system" {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
let message = match role {
|
|
286
|
+
"system" => ChatMessage::system(content),
|
|
287
|
+
"user" => ChatMessage::user(content),
|
|
288
|
+
"assistant" => ChatMessage::assistant(content),
|
|
289
|
+
// Tool results flatten to plain text (MVP; acceptable loss).
|
|
290
|
+
"tool" => ChatMessage::user(format!("[tool result]\n{content}")),
|
|
291
|
+
_ => continue,
|
|
292
|
+
};
|
|
293
|
+
self.engine.push_history(message).await;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async fn compact_history(&self, keep_last: usize) -> Result<(), BackendError> {
|
|
298
|
+
let snapshot = self.engine.history().await;
|
|
299
|
+
// One plain (tool-free) inference round produces the summary. On error
|
|
300
|
+
// history is untouched — send_message only mutates it on success, and
|
|
301
|
+
// whatever it appended is wiped by the clear below anyway.
|
|
302
|
+
let result = self
|
|
303
|
+
.engine
|
|
304
|
+
.send_message(SUMMARIZE_PROMPT)
|
|
305
|
+
.await
|
|
306
|
+
.map_err(|error| error.to_string())?;
|
|
307
|
+
// Local models may reason in <think> blocks; keep only the visible part.
|
|
308
|
+
let (_think, summary) = crate::chat::strip_think_blocks(&result.text);
|
|
309
|
+
|
|
310
|
+
self.engine.clear_history().await;
|
|
311
|
+
// Leading system messages carry the session context; keep them all.
|
|
312
|
+
for message in snapshot
|
|
313
|
+
.iter()
|
|
314
|
+
.take_while(|message| message.role == ChatRole::System)
|
|
315
|
+
{
|
|
316
|
+
self.engine.push_history(message.clone()).await;
|
|
317
|
+
}
|
|
318
|
+
self.engine
|
|
319
|
+
.push_history(ChatMessage::user(format!(
|
|
320
|
+
"[Conversation summary]\n{summary}"
|
|
321
|
+
)))
|
|
322
|
+
.await;
|
|
323
|
+
let non_system: Vec<&ChatMessage> = snapshot
|
|
324
|
+
.iter()
|
|
325
|
+
.filter(|message| message.role != ChatRole::System)
|
|
326
|
+
.collect();
|
|
327
|
+
let tail_start = non_system.len().saturating_sub(keep_last);
|
|
328
|
+
for message in &non_system[tail_start..] {
|
|
329
|
+
self.engine.push_history((*message).clone()).await;
|
|
330
|
+
}
|
|
331
|
+
Ok(())
|
|
332
|
+
}
|
|
201
333
|
}
|
|
202
334
|
|
|
203
335
|
/// Drain an onde streaming receiver, forwarding each token to `sink` and
|
|
@@ -562,9 +694,82 @@ impl InferenceBackend for OpenAiBackend {
|
|
|
562
694
|
self.complete(tools, sink).await
|
|
563
695
|
}
|
|
564
696
|
|
|
697
|
+
async fn record_cancelled_tool_results(&self, results: Vec<ToolResult>) {
|
|
698
|
+
let mut history = self.history.lock().await;
|
|
699
|
+
for result in results {
|
|
700
|
+
history.push(serde_json::json!({
|
|
701
|
+
"role": "tool",
|
|
702
|
+
"tool_call_id": result.tool_call_id,
|
|
703
|
+
"content": result.content,
|
|
704
|
+
}));
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
565
708
|
fn is_remote(&self) -> bool {
|
|
566
709
|
true
|
|
567
710
|
}
|
|
711
|
+
|
|
712
|
+
async fn history_snapshot(&self) -> Vec<serde_json::Value> {
|
|
713
|
+
self.history.lock().await.clone()
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async fn restore_history(&self, history: Vec<serde_json::Value>) {
|
|
717
|
+
// The snapshot includes the seeded system message, so a wholesale
|
|
718
|
+
// replacement restores exactly what was saved.
|
|
719
|
+
*self.history.lock().await = history;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async fn compact_history(&self, keep_last: usize) -> Result<(), BackendError> {
|
|
723
|
+
let snapshot: Vec<serde_json::Value> = self.history.lock().await.clone();
|
|
724
|
+
|
|
725
|
+
// Ask the endpoint for a summary of the conversation so far, through
|
|
726
|
+
// the ordinary completion machinery (non-streaming).
|
|
727
|
+
self.history
|
|
728
|
+
.lock()
|
|
729
|
+
.await
|
|
730
|
+
.push(serde_json::json!({ "role": "user", "content": SUMMARIZE_PROMPT }));
|
|
731
|
+
let summary = match self.complete(None, None).await {
|
|
732
|
+
Ok(result) => result.text,
|
|
733
|
+
Err(error) => {
|
|
734
|
+
// Roll back the summarization request; the turn never happened.
|
|
735
|
+
*self.history.lock().await = snapshot;
|
|
736
|
+
return Err(error);
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
let system = snapshot
|
|
741
|
+
.first()
|
|
742
|
+
.filter(|message| message["role"] == "system")
|
|
743
|
+
.cloned();
|
|
744
|
+
let non_system: Vec<serde_json::Value> = snapshot
|
|
745
|
+
.iter()
|
|
746
|
+
.filter(|message| message["role"] != "system")
|
|
747
|
+
.cloned()
|
|
748
|
+
.collect();
|
|
749
|
+
let tail_start = non_system.len().saturating_sub(keep_last);
|
|
750
|
+
let mut tail = non_system[tail_start..].to_vec();
|
|
751
|
+
// Drop leading tool results whose assistant tool-call message was
|
|
752
|
+
// summarized away — strict endpoints reject orphaned `role: "tool"`
|
|
753
|
+
// entries on the very next request.
|
|
754
|
+
while tail
|
|
755
|
+
.first()
|
|
756
|
+
.is_some_and(|message| message["role"] == "tool")
|
|
757
|
+
{
|
|
758
|
+
tail.remove(0);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
let mut rebuilt = Vec::new();
|
|
762
|
+
if let Some(system) = system {
|
|
763
|
+
rebuilt.push(system);
|
|
764
|
+
}
|
|
765
|
+
rebuilt.push(serde_json::json!({
|
|
766
|
+
"role": "user",
|
|
767
|
+
"content": format!("[Conversation summary]\n{summary}"),
|
|
768
|
+
}));
|
|
769
|
+
rebuilt.extend(tail);
|
|
770
|
+
*self.history.lock().await = rebuilt;
|
|
771
|
+
Ok(())
|
|
772
|
+
}
|
|
568
773
|
}
|
|
569
774
|
|
|
570
775
|
// ── OpenAI response shapes ────────────────────────────────────────────────────────
|
|
@@ -727,6 +932,189 @@ mod tests {
|
|
|
727
932
|
);
|
|
728
933
|
}
|
|
729
934
|
|
|
935
|
+
#[tokio::test]
|
|
936
|
+
async fn cancelled_tool_results_close_out_history() {
|
|
937
|
+
let backend = OpenAiBackend::new("http://localhost", "", "test-model", None);
|
|
938
|
+
backend
|
|
939
|
+
.history
|
|
940
|
+
.lock()
|
|
941
|
+
.await
|
|
942
|
+
.push(streamed_assistant_history(
|
|
943
|
+
"",
|
|
944
|
+
&[ToolCall {
|
|
945
|
+
id: "call_9".to_string(),
|
|
946
|
+
name: "run_command".to_string(),
|
|
947
|
+
arguments: r#"{"command":"ls"}"#.to_string(),
|
|
948
|
+
}],
|
|
949
|
+
));
|
|
950
|
+
|
|
951
|
+
backend
|
|
952
|
+
.record_cancelled_tool_results(vec![ToolResult {
|
|
953
|
+
tool_call_id: "call_9".to_string(),
|
|
954
|
+
content: "cancelled by the user".to_string(),
|
|
955
|
+
}])
|
|
956
|
+
.await;
|
|
957
|
+
|
|
958
|
+
let history = backend.history.lock().await;
|
|
959
|
+
let last = history.last().unwrap();
|
|
960
|
+
assert_eq!(last["role"], "tool");
|
|
961
|
+
assert_eq!(last["tool_call_id"], "call_9");
|
|
962
|
+
assert_eq!(last["content"], "cancelled by the user");
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
#[test]
|
|
966
|
+
fn estimate_tokens_scales_with_serialized_size() {
|
|
967
|
+
assert_eq!(estimate_tokens(&[]), 0);
|
|
968
|
+
|
|
969
|
+
let short = vec![serde_json::json!({ "role": "user", "content": "hi" })];
|
|
970
|
+
let long = vec![serde_json::json!({ "role": "user", "content": "x".repeat(4_000) })];
|
|
971
|
+
let short_estimate = estimate_tokens(&short);
|
|
972
|
+
let long_estimate = estimate_tokens(&long);
|
|
973
|
+
|
|
974
|
+
assert!(short_estimate > 0, "non-empty history estimates > 0 tokens");
|
|
975
|
+
assert!(long_estimate > short_estimate, "longer history costs more");
|
|
976
|
+
// 4,000 content chars / 4 ≈ 1,000 tokens, plus a little JSON framing.
|
|
977
|
+
assert!((1_000..1_100).contains(&long_estimate), "{long_estimate}");
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
#[tokio::test]
|
|
981
|
+
async fn openai_snapshot_restore_round_trips_exactly() {
|
|
982
|
+
let backend = OpenAiBackend::new("http://localhost", "", "m", Some("be helpful".into()));
|
|
983
|
+
{
|
|
984
|
+
let mut history = backend.history.lock().await;
|
|
985
|
+
history.push(serde_json::json!({ "role": "user", "content": "hello" }));
|
|
986
|
+
history.push(streamed_assistant_history(
|
|
987
|
+
"",
|
|
988
|
+
&[ToolCall {
|
|
989
|
+
id: "call_1".to_string(),
|
|
990
|
+
name: "read_file".to_string(),
|
|
991
|
+
arguments: r#"{"path":"a.rs"}"#.to_string(),
|
|
992
|
+
}],
|
|
993
|
+
));
|
|
994
|
+
history.push(serde_json::json!({
|
|
995
|
+
"role": "tool", "tool_call_id": "call_1", "content": "fn main() {}",
|
|
996
|
+
}));
|
|
997
|
+
history.push(serde_json::json!({ "role": "assistant", "content": "done" }));
|
|
998
|
+
}
|
|
999
|
+
let snapshot = backend.history_snapshot().await;
|
|
1000
|
+
assert_eq!(
|
|
1001
|
+
snapshot[0]["role"], "system",
|
|
1002
|
+
"snapshot keeps the system message"
|
|
1003
|
+
);
|
|
1004
|
+
|
|
1005
|
+
// Restoring into a backend seeded with a *different* system prompt must
|
|
1006
|
+
// replace everything, including that seed.
|
|
1007
|
+
let restored = OpenAiBackend::new("http://localhost", "", "m", Some("other seed".into()));
|
|
1008
|
+
restored.restore_history(snapshot.clone()).await;
|
|
1009
|
+
assert_eq!(restored.history_snapshot().await, snapshot);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/// Minimal scripted OpenAI-compatible endpoint: accepts one HTTP request on
|
|
1013
|
+
/// a std listener and answers with a fixed non-streaming completion.
|
|
1014
|
+
fn spawn_completion_stub(summary: &str) -> std::net::SocketAddr {
|
|
1015
|
+
use std::io::{Read, Write};
|
|
1016
|
+
|
|
1017
|
+
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|
1018
|
+
let addr = listener.local_addr().unwrap();
|
|
1019
|
+
let body = serde_json::json!({
|
|
1020
|
+
"choices": [{ "message": { "role": "assistant", "content": summary } }]
|
|
1021
|
+
})
|
|
1022
|
+
.to_string();
|
|
1023
|
+
std::thread::spawn(move || {
|
|
1024
|
+
let (mut stream, _) = listener.accept().unwrap();
|
|
1025
|
+
// Read until the full request (headers + content-length body) is in.
|
|
1026
|
+
let mut request = Vec::new();
|
|
1027
|
+
let mut chunk = [0u8; 4096];
|
|
1028
|
+
loop {
|
|
1029
|
+
let n = stream.read(&mut chunk).unwrap_or(0);
|
|
1030
|
+
if n == 0 {
|
|
1031
|
+
break;
|
|
1032
|
+
}
|
|
1033
|
+
request.extend_from_slice(&chunk[..n]);
|
|
1034
|
+
if let Some(headers_end) =
|
|
1035
|
+
request.windows(4).position(|window| window == b"\r\n\r\n")
|
|
1036
|
+
{
|
|
1037
|
+
let headers = String::from_utf8_lossy(&request[..headers_end]);
|
|
1038
|
+
let content_length = headers
|
|
1039
|
+
.lines()
|
|
1040
|
+
.find_map(|line| {
|
|
1041
|
+
line.to_ascii_lowercase()
|
|
1042
|
+
.strip_prefix("content-length:")
|
|
1043
|
+
.map(|value| value.trim().parse::<usize>().unwrap_or(0))
|
|
1044
|
+
})
|
|
1045
|
+
.unwrap_or(0);
|
|
1046
|
+
if request.len() >= headers_end + 4 + content_length {
|
|
1047
|
+
break;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
let response = format!(
|
|
1052
|
+
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
|
|
1053
|
+
content-length: {}\r\nconnection: close\r\n\r\n{}",
|
|
1054
|
+
body.len(),
|
|
1055
|
+
body
|
|
1056
|
+
);
|
|
1057
|
+
let _ = stream.write_all(response.as_bytes());
|
|
1058
|
+
});
|
|
1059
|
+
addr
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
#[tokio::test]
|
|
1063
|
+
async fn compact_history_rebuilds_system_summary_and_tail() {
|
|
1064
|
+
let addr = spawn_completion_stub("We refactored backend.rs; tests pass.");
|
|
1065
|
+
let backend = OpenAiBackend::new(
|
|
1066
|
+
format!("http://{addr}/v1"),
|
|
1067
|
+
"test-key",
|
|
1068
|
+
"test-model",
|
|
1069
|
+
Some("be helpful".into()),
|
|
1070
|
+
);
|
|
1071
|
+
{
|
|
1072
|
+
let mut history = backend.history.lock().await;
|
|
1073
|
+
for i in 0..5 {
|
|
1074
|
+
let role = if i % 2 == 0 { "user" } else { "assistant" };
|
|
1075
|
+
history.push(serde_json::json!({
|
|
1076
|
+
"role": role, "content": format!("message {i}"),
|
|
1077
|
+
}));
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
backend.compact_history(2).await.unwrap();
|
|
1082
|
+
|
|
1083
|
+
let history = backend.history_snapshot().await;
|
|
1084
|
+
assert_eq!(history.len(), 4, "system + summary + last 2: {history:?}");
|
|
1085
|
+
assert_eq!(history[0]["role"], "system");
|
|
1086
|
+
assert_eq!(history[0]["content"], "be helpful");
|
|
1087
|
+
assert_eq!(history[1]["role"], "user");
|
|
1088
|
+
let summary_text = history[1]["content"].as_str().unwrap();
|
|
1089
|
+
assert!(summary_text.starts_with("[Conversation summary]\n"));
|
|
1090
|
+
assert!(summary_text.contains("We refactored backend.rs; tests pass."));
|
|
1091
|
+
assert_eq!(
|
|
1092
|
+
history[2],
|
|
1093
|
+
serde_json::json!({ "role": "assistant", "content": "message 3" })
|
|
1094
|
+
);
|
|
1095
|
+
assert_eq!(
|
|
1096
|
+
history[3],
|
|
1097
|
+
serde_json::json!({ "role": "user", "content": "message 4" })
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
#[tokio::test]
|
|
1102
|
+
async fn compact_history_failure_leaves_history_intact() {
|
|
1103
|
+
// No listener at this address: the summarization request fails, and
|
|
1104
|
+
// history must roll back to exactly what it was.
|
|
1105
|
+
let backend =
|
|
1106
|
+
OpenAiBackend::new("http://127.0.0.1:9", "", "test-model", Some("sys".into()));
|
|
1107
|
+
backend
|
|
1108
|
+
.history
|
|
1109
|
+
.lock()
|
|
1110
|
+
.await
|
|
1111
|
+
.push(serde_json::json!({ "role": "user", "content": "hello" }));
|
|
1112
|
+
let before = backend.history_snapshot().await;
|
|
1113
|
+
|
|
1114
|
+
assert!(backend.compact_history(2).await.is_err());
|
|
1115
|
+
assert_eq!(backend.history_snapshot().await, before);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
730
1118
|
#[test]
|
|
731
1119
|
fn assistant_message_with_tool_calls_round_trips() {
|
|
732
1120
|
let message = ResponseMessage {
|