tether-sdk 0.0.2__py3-none-any.whl
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.
- tether/__init__.py +35 -0
- tether/cli.py +235 -0
- tether/context.py +170 -0
- tether/decorator.py +280 -0
- tether/harness.py +435 -0
- tether/multiplexer.py +124 -0
- tether/providers/__init__.py +66 -0
- tether/providers/anthropic.py +135 -0
- tether/providers/base.py +136 -0
- tether/providers/ollama.py +112 -0
- tether/providers/openai.py +89 -0
- tether/sandbox.py +342 -0
- tether/skills.py +157 -0
- tether/store.py +660 -0
- tether/tools/__init__.py +22 -0
- tether/tools/agent.py +190 -0
- tether/tools/bash.py +29 -0
- tether/tools/done.py +33 -0
- tether/tools/edit.py +107 -0
- tether/tools/mem.py +93 -0
- tether/tools/web.py +222 -0
- tether_sdk-0.0.2.dist-info/METADATA +346 -0
- tether_sdk-0.0.2.dist-info/RECORD +26 -0
- tether_sdk-0.0.2.dist-info/WHEEL +4 -0
- tether_sdk-0.0.2.dist-info/entry_points.txt +2 -0
- tether_sdk-0.0.2.dist-info/licenses/LICENSE +20 -0
tether/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Tether: General-purpose, headless-first LLM-Native micro-harness."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from tether.decorator import ToolSpec, tool
|
|
6
|
+
from tether.harness import Harness
|
|
7
|
+
from tether.providers import (
|
|
8
|
+
ActionCall,
|
|
9
|
+
ModelProvider,
|
|
10
|
+
ModelResponse,
|
|
11
|
+
extract_fallback_action,
|
|
12
|
+
register_provider,
|
|
13
|
+
)
|
|
14
|
+
from tether.sandbox import SandboxPolicy, SandboxViolationError
|
|
15
|
+
from tether.skills import Skill, SkillLoader
|
|
16
|
+
from tether.store import SessionRecord, SQLiteStore
|
|
17
|
+
|
|
18
|
+
__version__ = "0.0.2"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ActionCall",
|
|
22
|
+
"Harness",
|
|
23
|
+
"ModelProvider",
|
|
24
|
+
"ModelResponse",
|
|
25
|
+
"SQLiteStore",
|
|
26
|
+
"SandboxPolicy",
|
|
27
|
+
"SandboxViolationError",
|
|
28
|
+
"SessionRecord",
|
|
29
|
+
"Skill",
|
|
30
|
+
"SkillLoader",
|
|
31
|
+
"ToolSpec",
|
|
32
|
+
"extract_fallback_action",
|
|
33
|
+
"register_provider",
|
|
34
|
+
"tool",
|
|
35
|
+
]
|
tether/cli.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Headless-capable CLI entrypoint for Tether (`tether run|status|read|query|send|wait|list`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from tether.harness import Harness
|
|
11
|
+
from tether.sandbox import SandboxPolicy
|
|
12
|
+
from tether.store import SQLiteStore
|
|
13
|
+
|
|
14
|
+
_KNOWN_SUBCOMMANDS = {"run", "status", "read", "query", "send", "wait", "list", "_worker"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="tether",
|
|
20
|
+
description="Tether: Headless-first LLM-Native Micro-Harness",
|
|
21
|
+
)
|
|
22
|
+
sub = parser.add_subparsers(dest="subcommand")
|
|
23
|
+
|
|
24
|
+
# `tether run`
|
|
25
|
+
p_run = sub.add_parser("run", help="Run a task inline or as a detached headless session")
|
|
26
|
+
p_run.add_argument(
|
|
27
|
+
"--headless", action="store_true", help="Run detached in background and print session ID"
|
|
28
|
+
)
|
|
29
|
+
p_run.add_argument(
|
|
30
|
+
"--model", type=str, default=None, help="Model (e.g. ollama/qwen3.5:9b, openai/gpt-4o-mini)"
|
|
31
|
+
)
|
|
32
|
+
p_run.add_argument(
|
|
33
|
+
"--skill", type=str, default=None, help="Skill name from ./skills/ or ~/.tether/skills/"
|
|
34
|
+
)
|
|
35
|
+
p_run.add_argument(
|
|
36
|
+
"--tools", type=str, default=None, help="Comma-separated tools (e.g. web,mem,bash or all)"
|
|
37
|
+
)
|
|
38
|
+
p_run.add_argument(
|
|
39
|
+
"--sandbox",
|
|
40
|
+
type=str,
|
|
41
|
+
default="workspace-write",
|
|
42
|
+
choices=["workspace-write", "read-only"],
|
|
43
|
+
help="Deterministic sandbox profile",
|
|
44
|
+
)
|
|
45
|
+
p_run.add_argument("--max-steps", type=int, default=15, help="Max reasoning steps")
|
|
46
|
+
p_run.add_argument(
|
|
47
|
+
"--db", type=str, default=None, help="SQLite DB path (default .tether/tether.db)"
|
|
48
|
+
)
|
|
49
|
+
p_run.add_argument("goal", nargs="+", help="Task goal or instruction")
|
|
50
|
+
|
|
51
|
+
# `tether status`
|
|
52
|
+
p_status = sub.add_parser("status", help="Show live session status and token metrics")
|
|
53
|
+
p_status.add_argument("session_id", help="Session ID (e.g. ses_...)")
|
|
54
|
+
p_status.add_argument("--db", type=str, default=None)
|
|
55
|
+
|
|
56
|
+
# `tether read`
|
|
57
|
+
p_read = sub.add_parser("read", help="Read session final result, compact digest, or step log")
|
|
58
|
+
p_read.add_argument("session_id", help="Session ID")
|
|
59
|
+
p_read.add_argument("--digest", action="store_true", help="Show compact ~50-token digest card")
|
|
60
|
+
p_read.add_argument("--steps", action="store_true", help="Show step-by-step work log")
|
|
61
|
+
p_read.add_argument("--db", type=str, default=None)
|
|
62
|
+
|
|
63
|
+
# `tether query`
|
|
64
|
+
p_query = sub.add_parser(
|
|
65
|
+
"query", help="BM25 FTS5 RAG query over session deliverables & observations"
|
|
66
|
+
)
|
|
67
|
+
p_query.add_argument("session_id", help="Session ID")
|
|
68
|
+
p_query.add_argument("query", nargs="+", help="Search keywords")
|
|
69
|
+
p_query.add_argument("--top", type=int, default=3, help="Top K chunks to return")
|
|
70
|
+
p_query.add_argument("--db", type=str, default=None)
|
|
71
|
+
|
|
72
|
+
# `tether send`
|
|
73
|
+
p_send = sub.add_parser(
|
|
74
|
+
"send", help="Send a follow-up message to a running or completed session"
|
|
75
|
+
)
|
|
76
|
+
p_send.add_argument("session_id", help="Session ID")
|
|
77
|
+
p_send.add_argument("--headless", action="store_true", help="Resume in background if completed")
|
|
78
|
+
p_send.add_argument("--db", type=str, default=None)
|
|
79
|
+
p_send.add_argument("message", nargs="+", help="Follow-up instruction")
|
|
80
|
+
|
|
81
|
+
# `tether wait`
|
|
82
|
+
p_wait = sub.add_parser("wait", help="Wait for one or more headless sessions to finish")
|
|
83
|
+
p_wait.add_argument("session_ids", nargs="+", help="Session ID(s)")
|
|
84
|
+
p_wait.add_argument("--timeout", type=float, default=120.0)
|
|
85
|
+
p_wait.add_argument("--db", type=str, default=None)
|
|
86
|
+
|
|
87
|
+
# `tether list`
|
|
88
|
+
p_list = sub.add_parser("list", help="List recent sessions")
|
|
89
|
+
p_list.add_argument("--limit", type=int, default=20)
|
|
90
|
+
p_list.add_argument("--db", type=str, default=None)
|
|
91
|
+
|
|
92
|
+
# Internal `_worker`
|
|
93
|
+
p_worker = sub.add_parser("_worker")
|
|
94
|
+
p_worker.add_argument("session_id")
|
|
95
|
+
p_worker.add_argument("--db", type=str, default=None)
|
|
96
|
+
|
|
97
|
+
return parser
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def _async_main(argv: list[str]) -> int:
|
|
101
|
+
# Support shorthand `tether --headless "Goal"` by inserting `run` if needed
|
|
102
|
+
effective_argv = list(argv)
|
|
103
|
+
if (
|
|
104
|
+
effective_argv
|
|
105
|
+
and effective_argv[0] not in _KNOWN_SUBCOMMANDS
|
|
106
|
+
and effective_argv[0] not in ("-h", "--help")
|
|
107
|
+
):
|
|
108
|
+
effective_argv.insert(0, "run")
|
|
109
|
+
|
|
110
|
+
parser = build_parser()
|
|
111
|
+
if not effective_argv:
|
|
112
|
+
parser.print_help()
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
ns = parser.parse_args(effective_argv)
|
|
116
|
+
cmd = ns.subcommand
|
|
117
|
+
|
|
118
|
+
if cmd == "_worker":
|
|
119
|
+
harness = Harness(db_path=ns.db)
|
|
120
|
+
await harness.execute_session(ns.session_id)
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
if cmd == "run":
|
|
124
|
+
goal = " ".join(ns.goal).strip()
|
|
125
|
+
sandbox = SandboxPolicy(profile=ns.sandbox, workspace_root=Path.cwd())
|
|
126
|
+
harness = Harness(
|
|
127
|
+
model=ns.model,
|
|
128
|
+
skill=ns.skill,
|
|
129
|
+
tools=ns.tools,
|
|
130
|
+
sandbox=sandbox,
|
|
131
|
+
db_path=ns.db,
|
|
132
|
+
max_steps=ns.max_steps,
|
|
133
|
+
)
|
|
134
|
+
if ns.headless:
|
|
135
|
+
sid = harness.spawn(goal)
|
|
136
|
+
print(sid)
|
|
137
|
+
return 0
|
|
138
|
+
rec = await harness.run(goal)
|
|
139
|
+
if rec.result:
|
|
140
|
+
print(rec.result)
|
|
141
|
+
return 0 if rec.status == "COMPLETED" else 1
|
|
142
|
+
|
|
143
|
+
store = SQLiteStore(db_path=getattr(ns, "db", None))
|
|
144
|
+
|
|
145
|
+
if cmd == "status":
|
|
146
|
+
srec = store.get_session(ns.session_id)
|
|
147
|
+
if not srec:
|
|
148
|
+
print(f"Error: session `{ns.session_id}` not found.", file=sys.stderr)
|
|
149
|
+
return 1
|
|
150
|
+
turns = store.get_turns(srec.id)
|
|
151
|
+
last_cmd = next((t.tool_cmd for t in reversed(turns) if t.tool_cmd), "-")
|
|
152
|
+
print(
|
|
153
|
+
f"{'ID':<14} {'STATUS':<10} {'MODEL':<22} {'STEP':<7} {'PEAK_TOK':<9} LAST_CMD\n"
|
|
154
|
+
f"{srec.id:<14} {srec.status:<10} {srec.model:<22} "
|
|
155
|
+
f"{srec.step_count}/{srec.max_steps:<4} {srec.peak_tokens:<9} {last_cmd}"
|
|
156
|
+
)
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
if cmd == "read":
|
|
160
|
+
srec = store.get_session(ns.session_id)
|
|
161
|
+
if not srec:
|
|
162
|
+
print(f"Error: session `{ns.session_id}` not found.", file=sys.stderr)
|
|
163
|
+
return 1
|
|
164
|
+
if ns.steps:
|
|
165
|
+
turns = store.get_turns(srec.id)
|
|
166
|
+
for t in turns:
|
|
167
|
+
if t.tool_cmd:
|
|
168
|
+
print(f"[step {t.step}] act({t.tool_cmd!r})")
|
|
169
|
+
else:
|
|
170
|
+
print(f"[step {t.step} {t.role}] {t.content[:200]}")
|
|
171
|
+
return 0
|
|
172
|
+
if ns.digest:
|
|
173
|
+
print(store.get_session_digest(srec.id))
|
|
174
|
+
return 0
|
|
175
|
+
print(srec.result or store.get_session_digest(srec.id))
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
if cmd == "query":
|
|
179
|
+
q = " ".join(ns.query).strip()
|
|
180
|
+
hits = store.search_chunks(q, session_ids=[ns.session_id], top_k=ns.top)
|
|
181
|
+
if not hits:
|
|
182
|
+
print(f"No BM25 RAG chunks matched `{q}`.")
|
|
183
|
+
return 0
|
|
184
|
+
for h in hits:
|
|
185
|
+
print(f"[{h.session_id} {h.source_type}:{h.source_id}#{h.chunk_index}]\n{h.content}\n")
|
|
186
|
+
return 0
|
|
187
|
+
|
|
188
|
+
if cmd == "send":
|
|
189
|
+
msg = " ".join(ns.message).strip()
|
|
190
|
+
harness = Harness(db_path=ns.db)
|
|
191
|
+
rec = await harness.send(ns.session_id, msg, headless=ns.headless)
|
|
192
|
+
if ns.headless or rec.status in ("RUNNING", "PENDING"):
|
|
193
|
+
print(f"Message queued for `{rec.id}` [{rec.status}].")
|
|
194
|
+
else:
|
|
195
|
+
print(rec.result or f"[{rec.id} {rec.status}]")
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
if cmd == "wait":
|
|
199
|
+
elapsed = 0.0
|
|
200
|
+
while elapsed < ns.timeout:
|
|
201
|
+
if all(
|
|
202
|
+
(r := store.get_session(sid)) is None or r.status in ("COMPLETED", "FAILED")
|
|
203
|
+
for sid in ns.session_ids
|
|
204
|
+
):
|
|
205
|
+
break
|
|
206
|
+
await asyncio.sleep(0.05)
|
|
207
|
+
elapsed += 0.05
|
|
208
|
+
for sid in ns.session_ids:
|
|
209
|
+
print(store.get_session_digest(sid))
|
|
210
|
+
return 0
|
|
211
|
+
|
|
212
|
+
if cmd == "list":
|
|
213
|
+
sessions = store.list_sessions(limit=ns.limit)
|
|
214
|
+
if not sessions:
|
|
215
|
+
print("No sessions recorded.")
|
|
216
|
+
return 0
|
|
217
|
+
print(f"{'ID':<14} {'STATUS':<10} {'MODEL':<22} {'STEP':<7} GOAL")
|
|
218
|
+
for s in sessions:
|
|
219
|
+
g = s.goal.replace("\n", " ")[:48]
|
|
220
|
+
print(f"{s.id:<14} {s.status:<10} {s.model:<22} {s.step_count}/{s.max_steps:<4} {g}")
|
|
221
|
+
return 0
|
|
222
|
+
|
|
223
|
+
parser.print_help()
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def main(argv: list[str] | None = None) -> None:
|
|
228
|
+
args = sys.argv[1:] if argv is None else argv
|
|
229
|
+
code = asyncio.run(_async_main(args))
|
|
230
|
+
if argv is None:
|
|
231
|
+
raise SystemExit(code)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
if __name__ == "__main__": # pragma: no cover
|
|
235
|
+
main()
|
tether/context.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""State Plane: ContextAssembler, Ephemeral Observation Folding, and Token Estimator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from tether.store import ObservationRecord, TurnRecord
|
|
9
|
+
|
|
10
|
+
ACT_TOOL_SCHEMA: dict[str, Any] = {
|
|
11
|
+
"type": "function",
|
|
12
|
+
"function": {
|
|
13
|
+
"name": "act",
|
|
14
|
+
"description": "Run a CLI command from the toolbox.",
|
|
15
|
+
"parameters": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {
|
|
18
|
+
"cmd": {"type": "string", "description": "CLI command or `<cmd> --help`"},
|
|
19
|
+
"stdin": {"type": "string", "description": "Optional multiline input"},
|
|
20
|
+
},
|
|
21
|
+
"required": ["cmd"],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def estimate_tokens(text: str) -> int:
|
|
28
|
+
"""Fast deterministic token approximation (~4 chars per token)."""
|
|
29
|
+
if not text:
|
|
30
|
+
return 0
|
|
31
|
+
return max(1, (len(text) + 3) // 4)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def estimate_messages_tokens(
|
|
35
|
+
messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None
|
|
36
|
+
) -> int:
|
|
37
|
+
"""Estimate total tokens across messages and tool schema payload."""
|
|
38
|
+
total = 0
|
|
39
|
+
for msg in messages:
|
|
40
|
+
total += 4 # framing overhead per message
|
|
41
|
+
total += estimate_tokens(str(msg.get("content") or ""))
|
|
42
|
+
if msg.get("tool_calls"):
|
|
43
|
+
total += estimate_tokens(json.dumps(msg["tool_calls"]))
|
|
44
|
+
if tools:
|
|
45
|
+
total += estimate_tokens(json.dumps(tools))
|
|
46
|
+
return total
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def elide_active_observation(obs_id: str, raw_output: str, max_chars: int = 2400) -> str:
|
|
50
|
+
"""Slice oversized active observations into Head + Tail in L1."""
|
|
51
|
+
if len(raw_output) <= max_chars:
|
|
52
|
+
return f"[{obs_id}] {raw_output}"
|
|
53
|
+
head_len = int(max_chars * 0.6)
|
|
54
|
+
tail_len = max_chars - head_len
|
|
55
|
+
elided_count = len(raw_output) - (head_len + tail_len)
|
|
56
|
+
head = raw_output[:head_len].rstrip()
|
|
57
|
+
tail = raw_output[-tail_len:].lstrip()
|
|
58
|
+
marker = (
|
|
59
|
+
f"\n... [{obs_id}: {elided_count:,} middle chars elided in L1 | "
|
|
60
|
+
f"use `mem search <query>` or `mem recall {obs_id}`] ...\n"
|
|
61
|
+
)
|
|
62
|
+
return f"[{obs_id}] {head}{marker}{tail}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ContextAssembler:
|
|
66
|
+
"""Assembles KV-cache-friendly prompts with deterministic observation folding."""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
*,
|
|
71
|
+
fold_after_turns: int = 2,
|
|
72
|
+
fold_char_threshold: int = 200,
|
|
73
|
+
active_char_cap: int = 2400,
|
|
74
|
+
max_context_tokens: int = 8192,
|
|
75
|
+
) -> None:
|
|
76
|
+
self.fold_after_turns = fold_after_turns
|
|
77
|
+
self.fold_char_threshold = fold_char_threshold
|
|
78
|
+
self.active_char_cap = active_char_cap
|
|
79
|
+
self.max_context_tokens = max_context_tokens
|
|
80
|
+
self._static_system_prompt: str | None = None
|
|
81
|
+
|
|
82
|
+
def build_system_prompt(
|
|
83
|
+
self,
|
|
84
|
+
toolbox_index: str,
|
|
85
|
+
skill_instructions: str | None = None,
|
|
86
|
+
) -> str:
|
|
87
|
+
"""Build the immutable system prompt prefix once per session."""
|
|
88
|
+
if self._static_system_prompt is not None:
|
|
89
|
+
return self._static_system_prompt
|
|
90
|
+
|
|
91
|
+
parts = [
|
|
92
|
+
"You are an autonomous agent on Tether.",
|
|
93
|
+
"Use `act(cmd, stdin)` to run commands. Run `<cmd> --help` for flags.",
|
|
94
|
+
'Call `act("done", stdin="<final answer>")` when finished.',
|
|
95
|
+
"",
|
|
96
|
+
"[TOOLBOX]",
|
|
97
|
+
toolbox_index,
|
|
98
|
+
]
|
|
99
|
+
if skill_instructions and skill_instructions.strip():
|
|
100
|
+
parts.extend(["", "[SKILL]", skill_instructions.strip()])
|
|
101
|
+
|
|
102
|
+
self._static_system_prompt = "\n".join(parts)
|
|
103
|
+
return self._static_system_prompt
|
|
104
|
+
|
|
105
|
+
def assemble(
|
|
106
|
+
self,
|
|
107
|
+
*,
|
|
108
|
+
system_prompt: str,
|
|
109
|
+
goal: str,
|
|
110
|
+
turns: list[TurnRecord],
|
|
111
|
+
observations: dict[str, ObservationRecord],
|
|
112
|
+
pins: dict[str, str],
|
|
113
|
+
current_step: int,
|
|
114
|
+
) -> list[dict[str, Any]]:
|
|
115
|
+
"""Assemble the message array while preserving the static prefix and folding old outputs."""
|
|
116
|
+
messages: list[dict[str, Any]] = [
|
|
117
|
+
{"role": "system", "content": system_prompt},
|
|
118
|
+
{"role": "user", "content": f"Goal: {goal}"},
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
history_msgs: list[dict[str, Any]] = []
|
|
122
|
+
for turn in turns:
|
|
123
|
+
content = turn.content
|
|
124
|
+
if turn.obs_id and turn.obs_id in observations:
|
|
125
|
+
obs = observations[turn.obs_id]
|
|
126
|
+
age = current_step - turn.step
|
|
127
|
+
if age >= self.fold_after_turns and obs.char_len >= self.fold_char_threshold:
|
|
128
|
+
content = obs.summary
|
|
129
|
+
else:
|
|
130
|
+
content = elide_active_observation(
|
|
131
|
+
obs.id, obs.raw_output, max_chars=self.active_char_cap
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if turn.role == "assistant" and turn.tool_cmd:
|
|
135
|
+
call_preview = f"act({turn.tool_cmd!r})"
|
|
136
|
+
if turn.tool_stdin:
|
|
137
|
+
call_preview = f"act({turn.tool_cmd!r}, stdin=<{len(turn.tool_stdin)} chars>)"
|
|
138
|
+
text = f"{content}\n{call_preview}".strip() if content else call_preview
|
|
139
|
+
history_msgs.append({"role": "assistant", "content": text})
|
|
140
|
+
elif turn.role == "tool":
|
|
141
|
+
history_msgs.append({"role": "user", "content": f"Observation:\n{content}"})
|
|
142
|
+
else:
|
|
143
|
+
history_msgs.append({"role": turn.role, "content": content})
|
|
144
|
+
|
|
145
|
+
pin_block = ""
|
|
146
|
+
if pins:
|
|
147
|
+
pin_lines = ["[PINNED MEMORY]"] + [f"- {k}: {v}" for k, v in pins.items()]
|
|
148
|
+
pin_block = "\n".join(pin_lines)
|
|
149
|
+
|
|
150
|
+
# Enforce max_context_tokens by dropping oldest turns if needed
|
|
151
|
+
base_tokens = estimate_messages_tokens(messages, [ACT_TOOL_SCHEMA]) + estimate_tokens(
|
|
152
|
+
pin_block
|
|
153
|
+
)
|
|
154
|
+
while len(history_msgs) > 2:
|
|
155
|
+
total = base_tokens + estimate_messages_tokens(history_msgs)
|
|
156
|
+
if total <= self.max_context_tokens:
|
|
157
|
+
break
|
|
158
|
+
history_msgs.pop(0)
|
|
159
|
+
|
|
160
|
+
if pin_block:
|
|
161
|
+
if history_msgs and history_msgs[-1]["role"] == "user":
|
|
162
|
+
history_msgs[-1] = {
|
|
163
|
+
"role": "user",
|
|
164
|
+
"content": f"{history_msgs[-1]['content']}\n\n{pin_block}",
|
|
165
|
+
}
|
|
166
|
+
else:
|
|
167
|
+
history_msgs.append({"role": "user", "content": pin_block})
|
|
168
|
+
|
|
169
|
+
messages.extend(history_msgs)
|
|
170
|
+
return messages
|