couplet-core 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. couplet_core-0.1.0/.gitignore +18 -0
  2. couplet_core-0.1.0/LICENSE +17 -0
  3. couplet_core-0.1.0/PKG-INFO +57 -0
  4. couplet_core-0.1.0/README.md +29 -0
  5. couplet_core-0.1.0/pyproject.toml +49 -0
  6. couplet_core-0.1.0/src/couplet_core/__init__.py +3 -0
  7. couplet_core-0.1.0/src/couplet_core/_log.py +3 -0
  8. couplet_core-0.1.0/src/couplet_core/acs.py +231 -0
  9. couplet_core-0.1.0/src/couplet_core/agent/compact.py +209 -0
  10. couplet_core-0.1.0/src/couplet_core/agent/config.py +20 -0
  11. couplet_core-0.1.0/src/couplet_core/agent/context.py +124 -0
  12. couplet_core-0.1.0/src/couplet_core/agent/context_usage.py +151 -0
  13. couplet_core-0.1.0/src/couplet_core/agent/harness.py +466 -0
  14. couplet_core-0.1.0/src/couplet_core/agent/helpers.py +33 -0
  15. couplet_core-0.1.0/src/couplet_core/agent/routing.py +89 -0
  16. couplet_core-0.1.0/src/couplet_core/agent/tokens.py +58 -0
  17. couplet_core-0.1.0/src/couplet_core/agent/types.py +51 -0
  18. couplet_core-0.1.0/src/couplet_core/domain/__init__.py +8 -0
  19. couplet_core-0.1.0/src/couplet_core/domain/session.py +58 -0
  20. couplet_core-0.1.0/src/couplet_core/exceptions.py +14 -0
  21. couplet_core-0.1.0/src/couplet_core/llm/__init__.py +13 -0
  22. couplet_core-0.1.0/src/couplet_core/llm/config.py +20 -0
  23. couplet_core-0.1.0/src/couplet_core/llm/resilience/__init__.py +1 -0
  24. couplet_core-0.1.0/src/couplet_core/llm/resilience/error_classifier.py +1015 -0
  25. couplet_core-0.1.0/src/couplet_core/llm/resilience/retry.py +156 -0
  26. couplet_core-0.1.0/src/couplet_core/llm/service.py +235 -0
  27. couplet_core-0.1.0/src/couplet_core/ports/__init__.py +4 -0
  28. couplet_core-0.1.0/src/couplet_core/ports/config.py +9 -0
  29. couplet_core-0.1.0/src/couplet_core/ports/session.py +47 -0
  30. couplet_core-0.1.0/src/couplet_core/py.typed +1 -0
  31. couplet_core-0.1.0/src/couplet_core/skills/__init__.py +1 -0
  32. couplet_core-0.1.0/src/couplet_core/skills/_base/handlers.py +59 -0
  33. couplet_core-0.1.0/src/couplet_core/skills/_base/instructions.md +36 -0
  34. couplet_core-0.1.0/src/couplet_core/skills/_base/tools.json +78 -0
  35. couplet_core-0.1.0/src/couplet_core/skills/classifier.py +79 -0
  36. couplet_core-0.1.0/src/couplet_core/skills/loader.py +296 -0
  37. couplet_core-0.1.0/src/couplet_core/skills/playground_config.py +93 -0
  38. couplet_core-0.1.0/src/couplet_core/tools/__init__.py +179 -0
  39. couplet_core-0.1.0/src/couplet_core/util/__init__.py +1 -0
  40. couplet_core-0.1.0/src/couplet_core/util/time.py +5 -0
@@ -0,0 +1,18 @@
1
+ .npmrc
2
+
3
+ node_modules/
4
+ .next/
5
+ dist/
6
+ .turbo/
7
+ .env
8
+ .env.local
9
+ .test-token
10
+ tests/RESULTS.md
11
+ *.pyc
12
+ __pycache__/
13
+ .ruff_cache/
14
+ .venv/
15
+ uv.lock
16
+ .DS_Store
17
+ .source/
18
+ *.tsbuildinfo
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 Couplet Contributors
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: couplet-core
3
+ Version: 0.1.0
4
+ Summary: Couplet agent engine — ACS v1, harness, LLM client, tools, and skills
5
+ Project-URL: Homepage, https://github.com/turbomesh/couplet
6
+ Project-URL: Documentation, https://github.com/turbomesh/couplet/tree/main/packages/core
7
+ Project-URL: Repository, https://github.com/turbomesh/couplet
8
+ Project-URL: Issues, https://github.com/turbomesh/couplet/issues
9
+ Author: Couplet Contributors
10
+ License-Expression: Apache-2.0
11
+ License-File: LICENSE
12
+ Keywords: acs,agent,harness,llm,streaming
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: httpx>=0.28.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
25
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
26
+ Requires-Dist: ruff>=0.8.0; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # couplet-core
30
+
31
+ Framework-agnostic Couplet agent engine for Python.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install couplet-core
37
+ ```
38
+
39
+ ## What's included
40
+
41
+ - **ACS v1** — typed streaming events and SSE serialization
42
+ - **Agent harness** — tool loop with context compaction
43
+ - **LLM client** — OpenAI-compatible API with retry/resilience
44
+ - **Tools & skills** — handler registry and skill pack loader
45
+
46
+ ## Minimal embed
47
+
48
+ ```python
49
+ from couplet_core.acs import event_to_sse
50
+ from couplet_core.agent.harness import run_agent_turn
51
+ from couplet_core.agent.types import AgentTurn
52
+
53
+ async for event in run_agent_turn(turn, store=my_session_store):
54
+ print(event_to_sse(event))
55
+ ```
56
+
57
+ See [couplet-runtime](https://github.com/turbomesh/couplet) for the reference FastAPI service.
@@ -0,0 +1,29 @@
1
+ # couplet-core
2
+
3
+ Framework-agnostic Couplet agent engine for Python.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install couplet-core
9
+ ```
10
+
11
+ ## What's included
12
+
13
+ - **ACS v1** — typed streaming events and SSE serialization
14
+ - **Agent harness** — tool loop with context compaction
15
+ - **LLM client** — OpenAI-compatible API with retry/resilience
16
+ - **Tools & skills** — handler registry and skill pack loader
17
+
18
+ ## Minimal embed
19
+
20
+ ```python
21
+ from couplet_core.acs import event_to_sse
22
+ from couplet_core.agent.harness import run_agent_turn
23
+ from couplet_core.agent.types import AgentTurn
24
+
25
+ async for event in run_agent_turn(turn, store=my_session_store):
26
+ print(event_to_sse(event))
27
+ ```
28
+
29
+ See [couplet-runtime](https://github.com/turbomesh/couplet) for the reference FastAPI service.
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "couplet-core"
3
+ version = "0.1.0"
4
+ description = "Couplet agent engine — ACS v1, harness, LLM client, tools, and skills"
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ requires-python = ">=3.11"
8
+ authors = [{ name = "Couplet Contributors" }]
9
+ keywords = ["agent", "llm", "acs", "streaming", "harness"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: Apache Software License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Typing :: Typed",
19
+ ]
20
+ dependencies = [
21
+ "httpx>=0.28.0",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/turbomesh/couplet"
26
+ Documentation = "https://github.com/turbomesh/couplet/tree/main/packages/core"
27
+ Repository = "https://github.com/turbomesh/couplet"
28
+ Issues = "https://github.com/turbomesh/couplet/issues"
29
+
30
+ [project.optional-dependencies]
31
+ dev = ["pytest>=8.0.0", "pytest-asyncio>=0.24.0", "ruff>=0.8.0"]
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/couplet_core"]
39
+
40
+ [tool.hatch.build.targets.sdist]
41
+ include = ["src/couplet_core", "README.md", "LICENSE"]
42
+
43
+ [tool.pytest.ini_options]
44
+ asyncio_mode = "auto"
45
+ testpaths = ["tests"]
46
+
47
+ [tool.ruff]
48
+ line-length = 100
49
+ target-version = "py311"
@@ -0,0 +1,3 @@
1
+ """Couplet agent engine — ACS, harness, LLM, tools, and skills."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger("couplet_core")
@@ -0,0 +1,231 @@
1
+ """Structured streaming events — the agent→frontend delivery contract.
2
+
3
+ Defines a typed event vocabulary that names *what happened* without
4
+ prescribing *how it is rendered*. The frontend consumes these events to
5
+ update the conversation view, tool progress, task board, and goal state.
6
+
7
+ Backward compatibility: existing OpenAI-style SSE chunks are still emitted
8
+ as `MessageChunk` events (or passed through directly), while these events
9
+ provide richer presentation-layer state.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from dataclasses import asdict, dataclass, field
16
+ from typing import Any, Union
17
+
18
+
19
+ # ── Message (assistant text) events ──────────────────────────────────────────
20
+
21
+ @dataclass(frozen=True)
22
+ class MessageChunk:
23
+ """A delta of streamed assistant text."""
24
+ text: str
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class MessageStop:
29
+ """The current assistant message segment is complete."""
30
+ final: bool = False
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Commentary:
35
+ """A complete interim assistant message emitted between tool iterations."""
36
+ text: str
37
+
38
+
39
+ # ── Tool-call events ─────────────────────────────────────────────────────────
40
+
41
+ @dataclass(frozen=True)
42
+ class ToolCallChunk:
43
+ """A tool invocation has started or its arguments are still streaming."""
44
+ tool_name: str
45
+ preview: str | None = None
46
+ args: dict[str, Any] | None = None
47
+ index: int = 0
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class ToolCallFinished:
52
+ """A tool invocation completed."""
53
+ tool_name: str
54
+ duration: float = 0.0
55
+ ok: bool = True
56
+ index: int = 0
57
+
58
+
59
+ # ── Chain events ─────────────────────────────────────────────────────────────
60
+
61
+ @dataclass(frozen=True)
62
+ class ThinkingChunk:
63
+ """Incremental or final reasoning content."""
64
+ content: str
65
+ node_id: str | None = None
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class ToolCallEvent:
70
+ """A tool call persisted as a chain node."""
71
+ node_id: str
72
+ tool_name: str
73
+ tool_input: dict[str, Any] | None = None
74
+ title: str | None = None
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class ToolResultEvent:
79
+ """A tool result persisted as a chain node."""
80
+ node_id: str
81
+ tool_name: str
82
+ tool_output: dict[str, Any] | None = None
83
+ title: str | None = None
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class UserReplyEvent:
88
+ """A user reply to a clarify option, persisted as a chain node."""
89
+ node_id: str
90
+ content: str
91
+ parent_id: str | None = None
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class FinalEvent:
96
+ """The final summary (e.g. task_done) persisted as a chain node."""
97
+ node_id: str
98
+ content: str
99
+
100
+
101
+ # ── Task / Goal events ───────────────────────────────────────────────────────
102
+
103
+ # ── Error recovery events ────────────────────────────────────────────────────
104
+
105
+ @dataclass(frozen=True)
106
+ class Retrying:
107
+ """An LLM call is being retried after a recoverable error."""
108
+ attempt: int
109
+ max_retries: int
110
+ delay: float
111
+ reason: str
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class ContextUsageCategory:
116
+ """A slice of the context window budget."""
117
+ id: str
118
+ label: str
119
+ tokens: int
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class ContextUsageEvent:
124
+ """Current context window usage (display-only for the frontend ring)."""
125
+ budget_tokens: int
126
+ used_tokens: int
127
+ used_percent: int
128
+ categories: list[dict[str, Any]] = field(default_factory=list)
129
+ source: str = "estimated"
130
+ estimated_tokens: int | None = None
131
+ prompt_tokens: int | None = None
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class ContextCompressed:
136
+ """Context was compressed before retry."""
137
+ pass
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class FallbackModel:
142
+ """A fallback model/provider was selected."""
143
+ model: str | None = None
144
+
145
+
146
+ @dataclass(frozen=True)
147
+ class FatalError:
148
+ """An unrecoverable error occurred."""
149
+ message: str
150
+
151
+
152
+ # ── Gateway control / lifecycle events ───────────────────────────────────────
153
+
154
+ @dataclass(frozen=True)
155
+ class LongToolHint:
156
+ """A tool has been running for a while."""
157
+ tool_name: str = ""
158
+ duration: float = 0.0
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class ClarifyEvent:
163
+ """Agent needs user input before continuing."""
164
+ node_id: str
165
+ question: str
166
+ options: list[str] = field(default_factory=list)
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class GatewayNotice:
171
+ """A gateway-originated control message."""
172
+ kind: str
173
+ text: str = ""
174
+ extra: dict[str, Any] = field(default_factory=dict)
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class OpenPageEvent:
179
+ """Open a console page in the right-side iframe panel."""
180
+ url: str
181
+ title: str | None = None
182
+
183
+
184
+ @dataclass(frozen=True)
185
+ class OpenExternalLinkEvent:
186
+ """Open an external URL in a new browser tab."""
187
+ url: str
188
+ title: str | None = None
189
+
190
+
191
+ # ── Union type ───────────────────────────────────────────────────────────────
192
+
193
+ StreamEvent = Union[
194
+ MessageChunk,
195
+ MessageStop,
196
+ Commentary,
197
+ ToolCallChunk,
198
+ ToolCallFinished,
199
+ ThinkingChunk,
200
+ ToolCallEvent,
201
+ ToolResultEvent,
202
+ UserReplyEvent,
203
+ FinalEvent,
204
+ ClarifyEvent,
205
+ ContextUsageEvent,
206
+ Retrying,
207
+ ContextCompressed,
208
+ FallbackModel,
209
+ FatalError,
210
+ LongToolHint,
211
+ GatewayNotice,
212
+ OpenPageEvent,
213
+ OpenExternalLinkEvent,
214
+ ]
215
+
216
+
217
+ def event_to_sse(event: StreamEvent) -> str:
218
+ """Serialize a StreamEvent to an SSE data line.
219
+
220
+ Output shape: data: {"type":"event","event":{"kind":"...","...":"..."}}\n\n
221
+ """
222
+ payload = _event_to_payload(event)
223
+ return f'data: {json.dumps({"type": "event", "event": payload}, ensure_ascii=False)}\n\n'
224
+
225
+
226
+ def _event_to_payload(event: StreamEvent) -> dict[str, Any]:
227
+ """Convert a dataclass event to a frontend-friendly dict with a `kind`."""
228
+ data = asdict(event)
229
+ kind = type(event).__name__
230
+ # Remove leading underscore if any and ensure camelCase-ish consistency.
231
+ return {"kind": kind, **data}
@@ -0,0 +1,209 @@
1
+ """Automatic context compaction — transparent to the user."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from couplet_core.agent.config import (
10
+ COMPACT_RECENT_MESSAGES,
11
+ COMPACT_SUMMARY_MAX_CHARS,
12
+ COMPACT_THRESHOLD_RATIO,
13
+ COMPACTED_TOOL_MAX_CHARS,
14
+ MAX_CONTEXT_TOKENS,
15
+ STORED_SUMMARY_PREFIX,
16
+ )
17
+ from couplet_core.agent.tokens import approx_tokens, fit_context_budget
18
+
19
+
20
+ @dataclass
21
+ class CompactStats:
22
+ compacted: bool = False
23
+ tool_results_slimmed: int = 0
24
+ messages_replaced: int = 0
25
+ summary_generated: bool = False
26
+ summary_text: str = ""
27
+
28
+
29
+ def is_stored_summary_message(msg: dict[str, Any]) -> bool:
30
+ content = msg.get("content") or ""
31
+ return isinstance(content, str) and content.startswith(STORED_SUMMARY_PREFIX)
32
+
33
+
34
+ def merge_compact_summaries(existing: str | None, delta: str) -> str:
35
+ """Merge persisted and newly generated summaries into one block."""
36
+ parts = [part.strip() for part in (existing or "", delta) if part and part.strip()]
37
+ if not parts:
38
+ return ""
39
+ merged = "\n".join(parts)
40
+ if len(merged) <= COMPACT_SUMMARY_MAX_CHARS:
41
+ return merged
42
+ return merged[: COMPACT_SUMMARY_MAX_CHARS - 1] + "…"
43
+
44
+
45
+ def inject_compact_summary(
46
+ messages: list[dict[str, Any]],
47
+ summary: str,
48
+ ) -> list[dict[str, Any]]:
49
+ """Ensure exactly one stored-summary user message after system prompts."""
50
+ if not summary.strip():
51
+ return messages
52
+
53
+ system_msgs = [m for m in messages if m.get("role") == "system"]
54
+ rest = [m for m in messages if m.get("role") != "system" and not is_stored_summary_message(m)]
55
+ summary_msg = {
56
+ "role": "user",
57
+ "content": f"{STORED_SUMMARY_PREFIX}\n{summary.strip()}",
58
+ }
59
+ return system_msgs + [summary_msg] + rest
60
+
61
+
62
+ def build_compact_goal_state_patch(existing_goal_state: dict[str, Any] | None, summary: str) -> dict[str, Any]:
63
+ """Return goal_state fields to persist after generating a compact summary."""
64
+ if not summary:
65
+ return {}
66
+ state = dict(existing_goal_state or {})
67
+ state["compact_summary"] = summary
68
+ state["compact_version"] = int(state.get("compact_version") or 0) + 1
69
+ return state
70
+
71
+
72
+ def _slim_tool_content(tool_name: str, raw: str, *, compact: bool) -> str:
73
+ if not compact:
74
+ return raw
75
+ limit = COMPACTED_TOOL_MAX_CHARS
76
+ if len(raw) <= limit:
77
+ return raw
78
+
79
+ try:
80
+ data = json.loads(raw)
81
+ except json.JSONDecodeError:
82
+ return raw[:limit] + f"\n...[compacted, omitted {len(raw) - limit} chars]"
83
+
84
+ if isinstance(data, dict):
85
+ if data.get("error"):
86
+ return raw[:limit]
87
+ slim: dict[str, Any] = {"_compacted": True, "tool": tool_name}
88
+ for key in ("summary", "message", "detail", "job_id", "system_id", "id", "url", "status"):
89
+ if key in data and data[key] is not None:
90
+ slim[key] = data[key]
91
+ if isinstance(data.get("result"), (dict, list, str)):
92
+ slim["result"] = str(data["result"])[:200]
93
+ if len(slim) <= 2 and "result" not in slim:
94
+ slim["preview"] = str(data)[:300]
95
+ return json.dumps(slim, ensure_ascii=False)
96
+
97
+ if isinstance(data, list):
98
+ preview = data[:5]
99
+ slim = {
100
+ "_compacted": True,
101
+ "tool": tool_name,
102
+ "count": len(data),
103
+ "preview": preview,
104
+ }
105
+ return json.dumps(slim, ensure_ascii=False)
106
+
107
+ return raw[:limit] + "\n...[compacted]"
108
+
109
+
110
+ def _build_rule_summary(messages: list[dict[str, Any]]) -> str:
111
+ """Extract salient points from older messages for a compact summary."""
112
+ lines: list[str] = []
113
+ for msg in messages:
114
+ role = msg.get("role")
115
+ content = (msg.get("content") or "").strip()
116
+ if not content or is_stored_summary_message(msg):
117
+ continue
118
+
119
+ if role == "user":
120
+ if len(content) <= 300:
121
+ lines.append(f"• 用户: {content}")
122
+ elif role == "assistant" and content and not msg.get("tool_calls"):
123
+ snippet = content.replace("\n", " ")
124
+ if len(snippet) > 220:
125
+ snippet = snippet[:220] + "…"
126
+ lines.append(f"• 助手: {snippet}")
127
+ elif role == "tool":
128
+ name = msg.get("name") or "tool"
129
+ try:
130
+ data = json.loads(content)
131
+ if isinstance(data, dict) and data.get("error"):
132
+ lines.append(f"• 工具 {name} 失败: {data.get('error')}")
133
+ elif isinstance(data, list):
134
+ lines.append(f"• 工具 {name} 返回 {len(data)} 条记录")
135
+ elif isinstance(data, dict):
136
+ keys = ", ".join(list(data.keys())[:6])
137
+ lines.append(f"• 工具 {name} 已完成 ({keys})")
138
+ else:
139
+ lines.append(f"• 工具 {name} 已执行")
140
+ except json.JSONDecodeError:
141
+ lines.append(f"• 工具 {name} 已执行")
142
+
143
+ if not lines:
144
+ return ""
145
+ summary = "\n".join(lines[-12:])
146
+ if len(summary) > COMPACT_SUMMARY_MAX_CHARS:
147
+ summary = summary[: COMPACT_SUMMARY_MAX_CHARS - 1] + "…"
148
+ return summary
149
+
150
+
151
+ def apply_smart_compact(
152
+ messages: list[dict[str, Any]],
153
+ budget: int = MAX_CONTEXT_TOKENS,
154
+ *,
155
+ aggressive: bool = False,
156
+ ) -> tuple[list[dict[str, Any]], CompactStats]:
157
+ """Compact context for LLM without mutating stored chat history."""
158
+ stats = CompactStats()
159
+ if not messages:
160
+ return messages, stats
161
+
162
+ threshold = budget if aggressive else int(budget * COMPACT_THRESHOLD_RATIO)
163
+ if approx_tokens(messages) <= threshold and not aggressive:
164
+ return messages, stats
165
+
166
+ system_msgs = [m for m in messages if m.get("role") == "system"]
167
+ rest = [m for m in messages if m.get("role") != "system" and not is_stored_summary_message(m)]
168
+ stored_summary_msgs = [m for m in messages if is_stored_summary_message(m)]
169
+
170
+ boundary = max(0, len(rest) - COMPACT_RECENT_MESSAGES)
171
+ if boundary <= 0 and not aggressive:
172
+ trimmed = fit_context_budget(messages, budget)
173
+ if len(trimmed) < len(messages):
174
+ stats.compacted = True
175
+ stats.messages_replaced = len(messages) - len(trimmed)
176
+ return trimmed, stats
177
+
178
+ slimmed_rest: list[dict[str, Any]] = []
179
+ for idx, msg in enumerate(rest):
180
+ if msg.get("role") == "tool" and idx < boundary:
181
+ raw = msg.get("content") or ""
182
+ new_content = _slim_tool_content(msg.get("name") or "tool", raw, compact=True)
183
+ if new_content != raw:
184
+ stats.tool_results_slimmed += 1
185
+ stats.compacted = True
186
+ slimmed_rest.append({**msg, "content": new_content})
187
+ else:
188
+ slimmed_rest.append(msg)
189
+
190
+ combined = system_msgs + stored_summary_msgs + slimmed_rest
191
+ target = budget if aggressive else int(budget * COMPACT_THRESHOLD_RATIO)
192
+
193
+ if approx_tokens(combined) > target or aggressive:
194
+ older = slimmed_rest[:boundary]
195
+ recent = slimmed_rest[boundary:]
196
+ summary = _build_rule_summary(older)
197
+ if summary and older:
198
+ slimmed_rest = recent
199
+ stats.summary_generated = True
200
+ stats.summary_text = summary
201
+ stats.messages_replaced = len(older)
202
+ stats.compacted = True
203
+ combined = system_msgs + stored_summary_msgs + slimmed_rest
204
+
205
+ trimmed = fit_context_budget(combined, budget)
206
+ if len(trimmed) < len(messages):
207
+ stats.compacted = True
208
+
209
+ return trimmed, stats
@@ -0,0 +1,20 @@
1
+ """Agent runtime configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ MAX_TOOL_ROUNDS = int(os.getenv("AGENT_MAX_TOOL_ROUNDS", "12"))
8
+ MAX_CONTEXT_TOKENS = int(os.getenv("AGENT_MAX_CONTEXT_TOKENS", "80000"))
9
+ MAX_TOOL_RESULT_CHARS = int(os.getenv("AGENT_MAX_TOOL_RESULT_CHARS", "8000"))
10
+ TOOL_TIMEOUT_SECONDS = float(os.getenv("AGENT_TOOL_TIMEOUT_SECONDS", "60"))
11
+ LONG_TOOL_HINT_SECONDS = float(os.getenv("AGENT_LONG_TOOL_HINT_SECONDS", "3"))
12
+ DEFAULT_SKILL = os.getenv("DEFAULT_AGENT_SKILL", "baremetal")
13
+
14
+ # Smart compact (automatic, no user action)
15
+ COMPACT_RECENT_MESSAGES = int(os.getenv("AGENT_COMPACT_RECENT_MESSAGES", "24"))
16
+ COMPACT_THRESHOLD_RATIO = float(os.getenv("AGENT_COMPACT_THRESHOLD_RATIO", "0.65"))
17
+ COMPACTED_TOOL_MAX_CHARS = int(os.getenv("AGENT_COMPACTED_TOOL_MAX_CHARS", "1200"))
18
+ COMPACT_SUMMARY_MAX_CHARS = int(os.getenv("AGENT_COMPACT_SUMMARY_MAX_CHARS", "2500"))
19
+
20
+ STORED_SUMMARY_PREFIX = "[会话背景摘要]"