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.
- couplet_core-0.1.0/.gitignore +18 -0
- couplet_core-0.1.0/LICENSE +17 -0
- couplet_core-0.1.0/PKG-INFO +57 -0
- couplet_core-0.1.0/README.md +29 -0
- couplet_core-0.1.0/pyproject.toml +49 -0
- couplet_core-0.1.0/src/couplet_core/__init__.py +3 -0
- couplet_core-0.1.0/src/couplet_core/_log.py +3 -0
- couplet_core-0.1.0/src/couplet_core/acs.py +231 -0
- couplet_core-0.1.0/src/couplet_core/agent/compact.py +209 -0
- couplet_core-0.1.0/src/couplet_core/agent/config.py +20 -0
- couplet_core-0.1.0/src/couplet_core/agent/context.py +124 -0
- couplet_core-0.1.0/src/couplet_core/agent/context_usage.py +151 -0
- couplet_core-0.1.0/src/couplet_core/agent/harness.py +466 -0
- couplet_core-0.1.0/src/couplet_core/agent/helpers.py +33 -0
- couplet_core-0.1.0/src/couplet_core/agent/routing.py +89 -0
- couplet_core-0.1.0/src/couplet_core/agent/tokens.py +58 -0
- couplet_core-0.1.0/src/couplet_core/agent/types.py +51 -0
- couplet_core-0.1.0/src/couplet_core/domain/__init__.py +8 -0
- couplet_core-0.1.0/src/couplet_core/domain/session.py +58 -0
- couplet_core-0.1.0/src/couplet_core/exceptions.py +14 -0
- couplet_core-0.1.0/src/couplet_core/llm/__init__.py +13 -0
- couplet_core-0.1.0/src/couplet_core/llm/config.py +20 -0
- couplet_core-0.1.0/src/couplet_core/llm/resilience/__init__.py +1 -0
- couplet_core-0.1.0/src/couplet_core/llm/resilience/error_classifier.py +1015 -0
- couplet_core-0.1.0/src/couplet_core/llm/resilience/retry.py +156 -0
- couplet_core-0.1.0/src/couplet_core/llm/service.py +235 -0
- couplet_core-0.1.0/src/couplet_core/ports/__init__.py +4 -0
- couplet_core-0.1.0/src/couplet_core/ports/config.py +9 -0
- couplet_core-0.1.0/src/couplet_core/ports/session.py +47 -0
- couplet_core-0.1.0/src/couplet_core/py.typed +1 -0
- couplet_core-0.1.0/src/couplet_core/skills/__init__.py +1 -0
- couplet_core-0.1.0/src/couplet_core/skills/_base/handlers.py +59 -0
- couplet_core-0.1.0/src/couplet_core/skills/_base/instructions.md +36 -0
- couplet_core-0.1.0/src/couplet_core/skills/_base/tools.json +78 -0
- couplet_core-0.1.0/src/couplet_core/skills/classifier.py +79 -0
- couplet_core-0.1.0/src/couplet_core/skills/loader.py +296 -0
- couplet_core-0.1.0/src/couplet_core/skills/playground_config.py +93 -0
- couplet_core-0.1.0/src/couplet_core/tools/__init__.py +179 -0
- couplet_core-0.1.0/src/couplet_core/util/__init__.py +1 -0
- couplet_core-0.1.0/src/couplet_core/util/time.py +5 -0
|
@@ -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,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 = "[会话背景摘要]"
|