agentlings 0.2.0__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.
- agentlings/__init__.py +3 -0
- agentlings/__main__.py +238 -0
- agentlings/cli/__init__.py +1 -0
- agentlings/cli/_migrations.py +78 -0
- agentlings/cli/_templates.py +57 -0
- agentlings/cli/_version.py +33 -0
- agentlings/cli/init.py +122 -0
- agentlings/cli/upgrade.py +89 -0
- agentlings/config.py +260 -0
- agentlings/core/__init__.py +1 -0
- agentlings/core/completion.py +219 -0
- agentlings/core/llm.py +509 -0
- agentlings/core/loop.py +134 -0
- agentlings/core/memory_models.py +97 -0
- agentlings/core/memory_store.py +109 -0
- agentlings/core/models.py +231 -0
- agentlings/core/prompt.py +122 -0
- agentlings/core/scheduler.py +141 -0
- agentlings/core/sleep.py +393 -0
- agentlings/core/store.py +318 -0
- agentlings/core/task.py +1087 -0
- agentlings/core/telemetry.py +181 -0
- agentlings/log.py +23 -0
- agentlings/migrations/__init__.py +37 -0
- agentlings/migrations/m0001_seed.py +17 -0
- agentlings/protocol/__init__.py +1 -0
- agentlings/protocol/a2a.py +220 -0
- agentlings/protocol/a2a_task_store.py +150 -0
- agentlings/protocol/agent_card.py +83 -0
- agentlings/protocol/mcp.py +232 -0
- agentlings/server.py +247 -0
- agentlings/templates/__init__.py +1 -0
- agentlings/templates/default/.env.example +16 -0
- agentlings/templates/default/agent.yaml +14 -0
- agentlings/tools/__init__.py +1 -0
- agentlings/tools/builtins.py +307 -0
- agentlings/tools/memory.py +104 -0
- agentlings/tools/registry.py +154 -0
- agentlings-0.2.0.dist-info/METADATA +406 -0
- agentlings-0.2.0.dist-info/RECORD +43 -0
- agentlings-0.2.0.dist-info/WHEEL +4 -0
- agentlings-0.2.0.dist-info/entry_points.txt +2 -0
- agentlings-0.2.0.dist-info/licenses/LICENSE +21 -0
agentlings/config.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Agent configuration from environment variables and optional YAML agent definition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
12
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MemoryConfig(BaseModel):
|
|
18
|
+
"""Memory subsystem configuration.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
token_budget: Maximum tokens for the memory block injected into the system prompt.
|
|
22
|
+
injection_prompt: Override template for the memory injection block.
|
|
23
|
+
Receives ``{entries}`` placeholder. ``None`` uses the built-in default.
|
|
24
|
+
inject_into_prompt: When ``False``, the memory store is still active and
|
|
25
|
+
the ``memory_*`` tools are still callable, but no memory block is
|
|
26
|
+
stapled into the system prompt. The agent must read memory
|
|
27
|
+
explicitly via the tool. Useful for small/local models where the
|
|
28
|
+
prompt budget is precious.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
model_config = ConfigDict(extra="ignore")
|
|
32
|
+
|
|
33
|
+
token_budget: int = 2000
|
|
34
|
+
injection_prompt: str | None = None
|
|
35
|
+
inject_into_prompt: bool = True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SleepConfig(BaseModel):
|
|
39
|
+
"""Nightly sleep cycle configuration.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
enabled: When ``False``, the sleep cycle is not scheduled even if
|
|
43
|
+
the block is present. Set this for backends that lack the
|
|
44
|
+
Anthropic batches API (e.g. Ollama's compatibility layer).
|
|
45
|
+
schedule: Cron expression for when to run (default 2am daily).
|
|
46
|
+
journal_retention_days: How long to keep journal files.
|
|
47
|
+
conversation_retention_days: How long to keep JSONL conversation files.
|
|
48
|
+
memory_max_entries: Hard cap on memory entries after consolidation.
|
|
49
|
+
model: Model override for sleep LLM calls (``None`` uses agent default).
|
|
50
|
+
summary_prompt: Override for the per-conversation summary prompt.
|
|
51
|
+
consolidation_prompt: Override for the REM consolidation prompt.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
model_config = ConfigDict(extra="ignore")
|
|
55
|
+
|
|
56
|
+
enabled: bool = True
|
|
57
|
+
schedule: str = "0 2 * * *"
|
|
58
|
+
journal_retention_days: int = 30
|
|
59
|
+
conversation_retention_days: int = 14
|
|
60
|
+
memory_max_entries: int = 50
|
|
61
|
+
model: str | None = None
|
|
62
|
+
summary_prompt: str | None = None
|
|
63
|
+
consolidation_prompt: str | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TelemetryConfig(BaseModel):
|
|
67
|
+
"""OpenTelemetry configuration.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
enabled: Whether telemetry is active.
|
|
71
|
+
endpoint: OTLP collector endpoint URL.
|
|
72
|
+
protocol: Collector protocol (``"http"`` or ``"grpc"``).
|
|
73
|
+
service_name: Service name for spans and metrics.
|
|
74
|
+
insecure: Disable TLS for the collector connection.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
model_config = ConfigDict(extra="ignore")
|
|
78
|
+
|
|
79
|
+
enabled: bool = False
|
|
80
|
+
endpoint: str = "http://localhost:4318"
|
|
81
|
+
protocol: str = "http"
|
|
82
|
+
service_name: str = "agentling"
|
|
83
|
+
insecure: bool = True
|
|
84
|
+
headers: dict[str, str] = Field(default_factory=dict)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class SkillConfig(BaseModel):
|
|
88
|
+
"""A skill advertised in the Agent Card.
|
|
89
|
+
|
|
90
|
+
Attributes:
|
|
91
|
+
id: Unique skill identifier.
|
|
92
|
+
name: Human-readable skill name.
|
|
93
|
+
description: What this skill does.
|
|
94
|
+
tags: Searchable tags for discovery.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
id: str
|
|
98
|
+
name: str
|
|
99
|
+
description: str
|
|
100
|
+
tags: list[str] = Field(default_factory=list)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class AgentDefinition(BaseModel):
|
|
104
|
+
"""Agent identity and behaviour loaded from YAML.
|
|
105
|
+
|
|
106
|
+
Attributes:
|
|
107
|
+
name: Agent name used in the Agent Card and MCP tool.
|
|
108
|
+
description: Agent description for discovery.
|
|
109
|
+
tools: Tool names or groups to enable (e.g. ``["bash", "filesystem"]``).
|
|
110
|
+
skills: Skills to advertise in the Agent Card.
|
|
111
|
+
system_prompt: The system prompt sent to the LLM.
|
|
112
|
+
bash_timeout: Default timeout in seconds for bash tool commands.
|
|
113
|
+
data_dir_awareness: When ``True`` (default), append a system-prompt
|
|
114
|
+
block telling the agent where its data directory lives and how
|
|
115
|
+
to read journals and conversation logs. Set to ``False`` for
|
|
116
|
+
agents without filesystem tools or when the prompt budget is
|
|
117
|
+
tight (e.g. small local models).
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
model_config = ConfigDict(extra="ignore")
|
|
121
|
+
|
|
122
|
+
name: str = "agentling"
|
|
123
|
+
description: str = "A lightweight AI agent"
|
|
124
|
+
tools: list[str] = Field(default_factory=list)
|
|
125
|
+
skills: list[SkillConfig] = Field(default_factory=list)
|
|
126
|
+
system_prompt: str | None = None
|
|
127
|
+
bash_timeout: int = Field(ge=1, default=30)
|
|
128
|
+
data_dir_awareness: bool = True
|
|
129
|
+
memory: MemoryConfig | None = None
|
|
130
|
+
sleep: SleepConfig | None = None
|
|
131
|
+
telemetry: TelemetryConfig | None = None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class AgentConfig(BaseSettings):
|
|
135
|
+
"""Runtime configuration for an agentling instance.
|
|
136
|
+
|
|
137
|
+
Secrets and runtime settings come from environment variables.
|
|
138
|
+
Agent identity (name, description, skills, tools, system prompt) comes
|
|
139
|
+
from a YAML file pointed to by ``AGENT_CONFIG``.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
model_config = SettingsConfigDict(
|
|
143
|
+
env_file=".env",
|
|
144
|
+
env_file_encoding="utf-8",
|
|
145
|
+
extra="ignore",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
anthropic_api_key: str = ""
|
|
149
|
+
anthropic_base_url: str | None = None
|
|
150
|
+
agent_api_key: str = ""
|
|
151
|
+
agent_model: str = "claude-sonnet-4-6"
|
|
152
|
+
agent_max_tokens: int = 4096
|
|
153
|
+
agent_host: str = "0.0.0.0"
|
|
154
|
+
agent_port: int = 8420
|
|
155
|
+
agent_data_dir: Path = Path("./data")
|
|
156
|
+
agent_log_level: str = "INFO"
|
|
157
|
+
agent_llm_backend: Literal["anthropic", "mock"] = "anthropic"
|
|
158
|
+
agent_external_url: str | None = None
|
|
159
|
+
agent_config: str | None = None
|
|
160
|
+
agent_otel_endpoint: str | None = None
|
|
161
|
+
agent_otel_protocol: str = "http"
|
|
162
|
+
agent_otel_insecure: bool = True
|
|
163
|
+
agent_otel_headers: str = ""
|
|
164
|
+
agent_task_await_seconds: int = 60
|
|
165
|
+
|
|
166
|
+
_definition: AgentDefinition = AgentDefinition()
|
|
167
|
+
|
|
168
|
+
@model_validator(mode="after")
|
|
169
|
+
def _init(self) -> AgentConfig:
|
|
170
|
+
self.agent_data_dir.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
if self.agent_config:
|
|
172
|
+
self._definition = _load_definition(self.agent_config)
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def definition(self) -> AgentDefinition:
|
|
177
|
+
"""The agent definition loaded from YAML (or defaults)."""
|
|
178
|
+
return self._definition
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def agent_name(self) -> str:
|
|
182
|
+
"""Agent name from the YAML definition."""
|
|
183
|
+
return self._definition.name
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def agent_description(self) -> str:
|
|
187
|
+
"""Agent description from the YAML definition."""
|
|
188
|
+
return self._definition.description
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def enabled_tools(self) -> list[str]:
|
|
192
|
+
"""Tool names/groups to activate from the YAML definition."""
|
|
193
|
+
return self._definition.tools
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def system_prompt(self) -> str | None:
|
|
197
|
+
"""System prompt from the YAML definition."""
|
|
198
|
+
return self._definition.system_prompt
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def skills(self) -> list[SkillConfig]:
|
|
202
|
+
"""Skills to advertise from the YAML definition."""
|
|
203
|
+
return self._definition.skills
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def memory_config(self) -> MemoryConfig | None:
|
|
207
|
+
"""Memory configuration from the YAML definition."""
|
|
208
|
+
return self._definition.memory
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def sleep_config(self) -> SleepConfig | None:
|
|
212
|
+
"""Sleep cycle configuration from the YAML definition."""
|
|
213
|
+
return self._definition.sleep
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def telemetry_config(self) -> TelemetryConfig | None:
|
|
217
|
+
"""Telemetry configuration, with env vars overriding YAML values."""
|
|
218
|
+
base = self._definition.telemetry
|
|
219
|
+
if self.agent_otel_endpoint:
|
|
220
|
+
if base is None:
|
|
221
|
+
base = TelemetryConfig(enabled=True)
|
|
222
|
+
updates: dict[str, Any] = {
|
|
223
|
+
"enabled": True,
|
|
224
|
+
"endpoint": self.agent_otel_endpoint,
|
|
225
|
+
"protocol": self.agent_otel_protocol,
|
|
226
|
+
"insecure": self.agent_otel_insecure,
|
|
227
|
+
}
|
|
228
|
+
if self.agent_otel_headers:
|
|
229
|
+
updates["headers"] = _parse_headers(self.agent_otel_headers)
|
|
230
|
+
base = base.model_copy(update=updates)
|
|
231
|
+
return base
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _parse_headers(raw: str) -> dict[str, str]:
|
|
235
|
+
"""Parse a comma-separated ``key=value`` string into a headers dict.
|
|
236
|
+
|
|
237
|
+
Example: ``"Authorization=Bearer tok,X-Custom=val"``
|
|
238
|
+
"""
|
|
239
|
+
headers: dict[str, str] = {}
|
|
240
|
+
for pair in raw.split(","):
|
|
241
|
+
pair = pair.strip()
|
|
242
|
+
if "=" in pair:
|
|
243
|
+
k, v = pair.split("=", 1)
|
|
244
|
+
headers[k.strip()] = v.strip()
|
|
245
|
+
return headers
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _load_definition(path: str) -> AgentDefinition:
|
|
249
|
+
"""Load an ``AgentDefinition`` from a YAML file.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
path: Path to the YAML configuration file.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
A validated ``AgentDefinition``.
|
|
256
|
+
"""
|
|
257
|
+
raw = Path(path).read_text(encoding="utf-8")
|
|
258
|
+
data = yaml.safe_load(raw) or {}
|
|
259
|
+
logger.info("loaded agent definition from %s", path)
|
|
260
|
+
return AgentDefinition.model_validate(data)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core engine: message loop, LLM client, conversation store, and system prompt."""
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Reusable LLM completion cycle: call model, execute tools, repeat until terminal response."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import functools
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Awaitable, Callable
|
|
11
|
+
|
|
12
|
+
from agentlings.core.llm import BaseLLMClient, LLMResponse
|
|
13
|
+
from agentlings.core.telemetry import get_meter, otel_span
|
|
14
|
+
from agentlings.tools.registry import ToolRegistry
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CancellationRequested(Exception):
|
|
20
|
+
"""Raised inside ``run_completion`` when a cancel callback returns ``True``.
|
|
21
|
+
|
|
22
|
+
Callers are expected to catch this and apply their own cancel terminal
|
|
23
|
+
semantics (e.g. writing a ``TaskCancelled`` marker). The ``partial``
|
|
24
|
+
attribute carries a ``CompletionResult`` with turns completed so far.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, message: str, partial: "CompletionResult") -> None:
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.partial = partial
|
|
30
|
+
|
|
31
|
+
@functools.lru_cache(maxsize=1)
|
|
32
|
+
def _get_metrics() -> dict[str, Any]:
|
|
33
|
+
m = get_meter()
|
|
34
|
+
return {
|
|
35
|
+
"duration": m.create_histogram(
|
|
36
|
+
"agentling.completion.duration_seconds",
|
|
37
|
+
description="End-to-end completion cycle duration",
|
|
38
|
+
),
|
|
39
|
+
"turns": m.create_histogram(
|
|
40
|
+
"agentling.completion.turns",
|
|
41
|
+
description="Number of LLM turns per completion cycle",
|
|
42
|
+
),
|
|
43
|
+
"tool_calls": m.create_counter(
|
|
44
|
+
"agentling.tool.calls",
|
|
45
|
+
description="Total tool invocations",
|
|
46
|
+
),
|
|
47
|
+
"tool_errors": m.create_counter(
|
|
48
|
+
"agentling.tool.errors",
|
|
49
|
+
description="Tool execution errors",
|
|
50
|
+
),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class CompletionTurn:
|
|
56
|
+
"""A single assistant response and its corresponding tool results (if any).
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
response: The LLM response for this turn.
|
|
60
|
+
tool_results: Tool result content blocks returned to the LLM, empty for terminal turns.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
response: LLMResponse
|
|
64
|
+
tool_results: list[dict[str, Any]] = field(default_factory=list)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class CompletionResult:
|
|
69
|
+
"""Result of running the LLM completion cycle.
|
|
70
|
+
|
|
71
|
+
Attributes:
|
|
72
|
+
content: Anthropic-format content blocks from the final LLM response.
|
|
73
|
+
stop_reason: Why the model stopped generating.
|
|
74
|
+
turns: Every turn in the cycle, each pairing an LLM response with its tool results.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
content: list[dict[str, Any]]
|
|
78
|
+
stop_reason: str | None = None
|
|
79
|
+
turns: list[CompletionTurn] = field(default_factory=list)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def run_completion(
|
|
83
|
+
llm: BaseLLMClient,
|
|
84
|
+
system: list[dict[str, Any]],
|
|
85
|
+
messages: list[dict[str, Any]],
|
|
86
|
+
tools: ToolRegistry,
|
|
87
|
+
turn_callback: Callable[[CompletionTurn], Awaitable[None]] | None = None,
|
|
88
|
+
should_cancel: Callable[[], bool] | None = None,
|
|
89
|
+
) -> CompletionResult:
|
|
90
|
+
"""Run the LLM in a loop, executing tool calls until a terminal text response.
|
|
91
|
+
|
|
92
|
+
This is the core interaction cycle extracted from the message loop so that
|
|
93
|
+
both conversation handling and the sleep cycle can reuse it.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
llm: The LLM client to use for completions.
|
|
97
|
+
system: System prompt blocks.
|
|
98
|
+
messages: Conversation messages (mutated in place with tool results).
|
|
99
|
+
tools: Registry of available tools.
|
|
100
|
+
turn_callback: Optional async callback invoked after each completed turn
|
|
101
|
+
(with the turn that just concluded). Used by the task engine to
|
|
102
|
+
journal per-turn progress into the sub-journal.
|
|
103
|
+
should_cancel: Optional synchronous predicate checked at cooperative
|
|
104
|
+
checkpoints — after each LLM call, before each tool batch, and
|
|
105
|
+
after each tool batch. When it returns ``True``,
|
|
106
|
+
``CancellationRequested`` is raised and all partial progress so
|
|
107
|
+
far is available in ``CompletionResult`` via a raised exception
|
|
108
|
+
attribute.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
The final response content and all intermediate responses.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
CancellationRequested: If ``should_cancel`` returns ``True`` at a
|
|
115
|
+
checkpoint. The exception carries a ``partial`` attribute with a
|
|
116
|
+
``CompletionResult`` containing turns completed so far.
|
|
117
|
+
"""
|
|
118
|
+
tool_schemas = tools.list_schemas()
|
|
119
|
+
turns: list[CompletionTurn] = []
|
|
120
|
+
cycle_start = time.monotonic()
|
|
121
|
+
metrics = _get_metrics()
|
|
122
|
+
|
|
123
|
+
def _check_cancel() -> None:
|
|
124
|
+
if should_cancel is not None and should_cancel():
|
|
125
|
+
partial = CompletionResult(
|
|
126
|
+
content=turns[-1].response.content if turns else [],
|
|
127
|
+
stop_reason="cancelled",
|
|
128
|
+
turns=turns,
|
|
129
|
+
)
|
|
130
|
+
raise CancellationRequested("cancellation requested", partial)
|
|
131
|
+
|
|
132
|
+
with otel_span("agentling.completion") as cycle_span:
|
|
133
|
+
while True:
|
|
134
|
+
turn_number = len(turns) + 1
|
|
135
|
+
|
|
136
|
+
with otel_span("agentling.completion.llm_call", {"completion.turn": turn_number}):
|
|
137
|
+
response = await llm.complete(system, messages, tool_schemas)
|
|
138
|
+
|
|
139
|
+
_check_cancel()
|
|
140
|
+
|
|
141
|
+
has_tool_use = any(
|
|
142
|
+
block.get("type") == "tool_use" for block in response.content
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if not has_tool_use:
|
|
146
|
+
terminal_turn = CompletionTurn(response=response)
|
|
147
|
+
turns.append(terminal_turn)
|
|
148
|
+
if turn_callback is not None:
|
|
149
|
+
await turn_callback(terminal_turn)
|
|
150
|
+
elapsed = time.monotonic() - cycle_start
|
|
151
|
+
logger.info(
|
|
152
|
+
"completion finished: %d turn(s) in %.1fs, stop_reason=%s",
|
|
153
|
+
len(turns), elapsed, response.stop_reason,
|
|
154
|
+
)
|
|
155
|
+
cycle_span.set_attribute("completion.turns", len(turns))
|
|
156
|
+
cycle_span.set_attribute("completion.stop_reason", response.stop_reason or "unknown")
|
|
157
|
+
cycle_span.set_attribute("completion.duration_s", round(elapsed, 2))
|
|
158
|
+
metrics["duration"].record(elapsed)
|
|
159
|
+
metrics["turns"].record(len(turns))
|
|
160
|
+
return CompletionResult(
|
|
161
|
+
content=response.content,
|
|
162
|
+
stop_reason=response.stop_reason,
|
|
163
|
+
turns=turns,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
messages.append({"role": "assistant", "content": response.content})
|
|
167
|
+
|
|
168
|
+
tool_calls = [b for b in response.content if b.get("type") == "tool_use"]
|
|
169
|
+
logger.info(
|
|
170
|
+
"turn %d: executing %d tool(s): %s",
|
|
171
|
+
turn_number, len(tool_calls),
|
|
172
|
+
", ".join(b["name"] for b in tool_calls),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
_check_cancel()
|
|
176
|
+
|
|
177
|
+
async def _exec_tool(block: dict[str, Any]) -> dict[str, Any]:
|
|
178
|
+
try:
|
|
179
|
+
with otel_span("agentling.completion.tool_exec", {
|
|
180
|
+
"tool.name": block["name"],
|
|
181
|
+
"completion.turn": turn_number,
|
|
182
|
+
}) as tool_span:
|
|
183
|
+
result = await tools.execute(block["name"], block.get("input", {}))
|
|
184
|
+
tool_span.set_attribute("tool.is_error", result.is_error)
|
|
185
|
+
|
|
186
|
+
tool_attrs = {"tool.name": block["name"]}
|
|
187
|
+
metrics["tool_calls"].add(1, tool_attrs)
|
|
188
|
+
if result.is_error:
|
|
189
|
+
metrics["tool_errors"].add(1, tool_attrs)
|
|
190
|
+
logger.warning("tool %s returned error: %.200s", block["name"], result.output)
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
"type": "tool_result",
|
|
194
|
+
"tool_use_id": block["id"],
|
|
195
|
+
"content": result.output,
|
|
196
|
+
"is_error": result.is_error,
|
|
197
|
+
}
|
|
198
|
+
except asyncio.CancelledError:
|
|
199
|
+
raise
|
|
200
|
+
except Exception:
|
|
201
|
+
logger.exception("unhandled exception in tool %s", block["name"])
|
|
202
|
+
metrics["tool_calls"].add(1, {"tool.name": block["name"]})
|
|
203
|
+
metrics["tool_errors"].add(1, {"tool.name": block["name"]})
|
|
204
|
+
return {
|
|
205
|
+
"type": "tool_result",
|
|
206
|
+
"tool_use_id": block["id"],
|
|
207
|
+
"content": f"Tool {block['name']} failed with an internal error",
|
|
208
|
+
"is_error": True,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
tool_results = list(await asyncio.gather(*(_exec_tool(b) for b in tool_calls)))
|
|
212
|
+
|
|
213
|
+
_check_cancel()
|
|
214
|
+
|
|
215
|
+
turn = CompletionTurn(response=response, tool_results=tool_results)
|
|
216
|
+
turns.append(turn)
|
|
217
|
+
if turn_callback is not None:
|
|
218
|
+
await turn_callback(turn)
|
|
219
|
+
messages.append({"role": "user", "content": tool_results})
|