lite-agent-sdk 0.1.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.
- lite_agent_sdk/__init__.py +41 -0
- lite_agent_sdk/agent.py +562 -0
- lite_agent_sdk/errors.py +19 -0
- lite_agent_sdk/providers/__init__.py +29 -0
- lite_agent_sdk/providers/anthropic_provider.py +332 -0
- lite_agent_sdk/providers/base.py +74 -0
- lite_agent_sdk/providers/openai_provider.py +376 -0
- lite_agent_sdk/tools/__init__.py +4 -0
- lite_agent_sdk/tools/base.py +20 -0
- lite_agent_sdk/tools/decorator.py +143 -0
- lite_agent_sdk/types.py +107 -0
- lite_agent_sdk-0.1.0.dist-info/METADATA +81 -0
- lite_agent_sdk-0.1.0.dist-info/RECORD +15 -0
- lite_agent_sdk-0.1.0.dist-info/WHEEL +4 -0
- lite_agent_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Minimal, lightweight, provider-agnostic LLM agent SDK."""
|
|
2
|
+
|
|
3
|
+
from lite_agent_sdk.agent import Agent
|
|
4
|
+
from lite_agent_sdk.errors import AgentError
|
|
5
|
+
from lite_agent_sdk.tools import BaseTool, ToolContext, tool
|
|
6
|
+
from lite_agent_sdk.types import (
|
|
7
|
+
AgentEvent,
|
|
8
|
+
Message,
|
|
9
|
+
RunComplete,
|
|
10
|
+
RunResult,
|
|
11
|
+
RunStarted,
|
|
12
|
+
TextDelta,
|
|
13
|
+
ToolCall,
|
|
14
|
+
ToolCallEnd,
|
|
15
|
+
ToolCallRecord,
|
|
16
|
+
ToolCallStart,
|
|
17
|
+
ToolResult,
|
|
18
|
+
Usage,
|
|
19
|
+
UsageUpdate,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Agent",
|
|
24
|
+
"AgentError",
|
|
25
|
+
"AgentEvent",
|
|
26
|
+
"BaseTool",
|
|
27
|
+
"Message",
|
|
28
|
+
"RunComplete",
|
|
29
|
+
"RunResult",
|
|
30
|
+
"RunStarted",
|
|
31
|
+
"TextDelta",
|
|
32
|
+
"ToolCall",
|
|
33
|
+
"ToolCallEnd",
|
|
34
|
+
"ToolCallRecord",
|
|
35
|
+
"ToolCallStart",
|
|
36
|
+
"ToolContext",
|
|
37
|
+
"ToolResult",
|
|
38
|
+
"Usage",
|
|
39
|
+
"UsageUpdate",
|
|
40
|
+
"tool",
|
|
41
|
+
]
|
lite_agent_sdk/agent.py
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import AsyncIterator, Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ValidationError
|
|
11
|
+
|
|
12
|
+
from lite_agent_sdk.errors import AgentError
|
|
13
|
+
from lite_agent_sdk.providers import _ADAPTER_FACTORIES, create_adapter
|
|
14
|
+
from lite_agent_sdk.providers.base import (
|
|
15
|
+
ModelFinish,
|
|
16
|
+
ModelRequest,
|
|
17
|
+
ModelResponse,
|
|
18
|
+
ProviderAdapter,
|
|
19
|
+
ProviderFinish,
|
|
20
|
+
ProviderTextDelta,
|
|
21
|
+
ProviderToolCall,
|
|
22
|
+
ProviderToolCallStart,
|
|
23
|
+
ProviderUsage,
|
|
24
|
+
ToolSchema,
|
|
25
|
+
)
|
|
26
|
+
from lite_agent_sdk.tools.base import BaseTool, ToolContext
|
|
27
|
+
from lite_agent_sdk.types import (
|
|
28
|
+
AgentEvent,
|
|
29
|
+
FinishReason,
|
|
30
|
+
Message,
|
|
31
|
+
RunComplete,
|
|
32
|
+
RunResult,
|
|
33
|
+
RunStarted,
|
|
34
|
+
TextDelta,
|
|
35
|
+
ToolCall,
|
|
36
|
+
ToolCallEnd,
|
|
37
|
+
ToolCallRecord,
|
|
38
|
+
ToolCallStart,
|
|
39
|
+
ToolResult,
|
|
40
|
+
Usage,
|
|
41
|
+
UsageUpdate,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _resolve_api_key(provider_name: str, api_key: str | None) -> str:
|
|
46
|
+
if api_key is not None:
|
|
47
|
+
return api_key
|
|
48
|
+
env_var = "OPENAI_API_KEY" if provider_name == "openai" else "ANTHROPIC_API_KEY"
|
|
49
|
+
resolved = os.environ.get(env_var)
|
|
50
|
+
if not resolved:
|
|
51
|
+
raise AgentError(
|
|
52
|
+
"missing_api_key",
|
|
53
|
+
f"missing api key for provider {provider_name!r} (set api_key= or {env_var})",
|
|
54
|
+
)
|
|
55
|
+
return resolved
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class _StreamHopDone:
|
|
60
|
+
response: ModelResponse
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Agent:
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
model: str,
|
|
67
|
+
system_prompt: str,
|
|
68
|
+
api_key: str | None = None,
|
|
69
|
+
tools: Sequence[BaseTool] | None = None,
|
|
70
|
+
output_schema: type[BaseModel] | None = None,
|
|
71
|
+
temperature: float | None = None,
|
|
72
|
+
top_p: float | None = None,
|
|
73
|
+
max_tokens: int | None = None,
|
|
74
|
+
stop: list[str] | None = None,
|
|
75
|
+
provider_options: dict[str, Any] | None = None,
|
|
76
|
+
*,
|
|
77
|
+
adapter: ProviderAdapter | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
if output_schema is not None and not issubclass(output_schema, BaseModel):
|
|
80
|
+
raise TypeError("output_schema must be a BaseModel subclass")
|
|
81
|
+
|
|
82
|
+
provider_name, sep, model_id = model.partition("/")
|
|
83
|
+
if not sep or not provider_name or not model_id:
|
|
84
|
+
raise AgentError("unknown_provider", f"invalid model id: {model!r}")
|
|
85
|
+
|
|
86
|
+
if adapter is not None:
|
|
87
|
+
self._adapter = adapter
|
|
88
|
+
elif provider_name in _ADAPTER_FACTORIES:
|
|
89
|
+
resolved_key = _resolve_api_key(provider_name, api_key)
|
|
90
|
+
self._adapter = create_adapter(provider_name, resolved_key)
|
|
91
|
+
else:
|
|
92
|
+
raise AgentError("unknown_provider", f"unknown provider {provider_name!r}")
|
|
93
|
+
|
|
94
|
+
tool_list = list(tools or [])
|
|
95
|
+
names = [t.name for t in tool_list]
|
|
96
|
+
if len(names) != len(set(names)):
|
|
97
|
+
raise ValueError("duplicate tool names")
|
|
98
|
+
|
|
99
|
+
self.model = model
|
|
100
|
+
self._model_id = model_id
|
|
101
|
+
self.system_prompt = system_prompt
|
|
102
|
+
self.api_key = api_key
|
|
103
|
+
self.tools = tool_list
|
|
104
|
+
self.output_schema = output_schema
|
|
105
|
+
self.temperature = temperature
|
|
106
|
+
self.top_p = top_p
|
|
107
|
+
self.max_tokens = max_tokens
|
|
108
|
+
self.stop = stop
|
|
109
|
+
self.provider_options = provider_options
|
|
110
|
+
self._tool_map = {t.name: t for t in self.tools}
|
|
111
|
+
|
|
112
|
+
async def run(self, messages: list[Message]) -> RunResult:
|
|
113
|
+
run_id = str(uuid4())
|
|
114
|
+
started = time.monotonic()
|
|
115
|
+
transcript = list(messages)
|
|
116
|
+
usage_total = Usage()
|
|
117
|
+
|
|
118
|
+
pending, resolved, _ = await self._resolve_unresolved(transcript, run_id)
|
|
119
|
+
if pending:
|
|
120
|
+
return self._build_result(
|
|
121
|
+
transcript,
|
|
122
|
+
usage_total,
|
|
123
|
+
run_id,
|
|
124
|
+
"waiting_approval",
|
|
125
|
+
output="",
|
|
126
|
+
started=started,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
need_hop = resolved or not _unresolved_calls(transcript)
|
|
130
|
+
|
|
131
|
+
while need_hop:
|
|
132
|
+
response = await self._model_hop(transcript, run_id)
|
|
133
|
+
usage_total = _merge_usage(usage_total, response.usage)
|
|
134
|
+
|
|
135
|
+
if response.finish == "length":
|
|
136
|
+
return self._build_result(
|
|
137
|
+
transcript,
|
|
138
|
+
usage_total,
|
|
139
|
+
run_id,
|
|
140
|
+
"length",
|
|
141
|
+
output=response.message.content or "",
|
|
142
|
+
started=started,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
assistant = response.message
|
|
146
|
+
if assistant.tool_calls:
|
|
147
|
+
stamped = _stamp_approvals(assistant.tool_calls, self._tool_map)
|
|
148
|
+
assistant = assistant.model_copy(update={"tool_calls": stamped})
|
|
149
|
+
transcript.append(assistant)
|
|
150
|
+
|
|
151
|
+
if not assistant.tool_calls:
|
|
152
|
+
output = self._final_output(assistant.content or "", run_id)
|
|
153
|
+
return self._build_result(
|
|
154
|
+
transcript,
|
|
155
|
+
usage_total,
|
|
156
|
+
run_id,
|
|
157
|
+
"stop",
|
|
158
|
+
output=output,
|
|
159
|
+
started=started,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
pending, resolved, _ = await self._resolve_unresolved(transcript, run_id)
|
|
163
|
+
if pending:
|
|
164
|
+
return self._build_result(
|
|
165
|
+
transcript,
|
|
166
|
+
usage_total,
|
|
167
|
+
run_id,
|
|
168
|
+
"waiting_approval",
|
|
169
|
+
output="",
|
|
170
|
+
started=started,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
need_hop = resolved
|
|
174
|
+
|
|
175
|
+
raise RuntimeError("agent loop ended without a terminal state")
|
|
176
|
+
|
|
177
|
+
def stream(self, messages: list[Message]) -> AsyncIterator[AgentEvent]:
|
|
178
|
+
async def _gen() -> AsyncIterator[AgentEvent]:
|
|
179
|
+
run_id = str(uuid4())
|
|
180
|
+
started = time.monotonic()
|
|
181
|
+
transcript = list(messages)
|
|
182
|
+
usage_total = Usage()
|
|
183
|
+
|
|
184
|
+
yield RunStarted(run_id=run_id, model=self.model)
|
|
185
|
+
|
|
186
|
+
pending, resolved, resolve_events = await self._resolve_unresolved(
|
|
187
|
+
transcript, run_id, emit_tool_results=True
|
|
188
|
+
)
|
|
189
|
+
for event in resolve_events:
|
|
190
|
+
yield event
|
|
191
|
+
if pending:
|
|
192
|
+
yield RunComplete(
|
|
193
|
+
result=self._build_result(
|
|
194
|
+
transcript,
|
|
195
|
+
usage_total,
|
|
196
|
+
run_id,
|
|
197
|
+
"waiting_approval",
|
|
198
|
+
output="",
|
|
199
|
+
started=started,
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
need_hop = resolved or not _unresolved_calls(transcript)
|
|
205
|
+
|
|
206
|
+
while need_hop:
|
|
207
|
+
response: ModelResponse | None = None
|
|
208
|
+
async for event in self._adapter_stream_hop(transcript, usage_total):
|
|
209
|
+
if isinstance(event, _StreamHopDone):
|
|
210
|
+
response = event.response
|
|
211
|
+
continue
|
|
212
|
+
if isinstance(event, UsageUpdate):
|
|
213
|
+
usage_total = event.usage_total
|
|
214
|
+
yield event
|
|
215
|
+
|
|
216
|
+
if response is None:
|
|
217
|
+
raise RuntimeError("adapter stream ended without a model response")
|
|
218
|
+
|
|
219
|
+
if response.finish == "length":
|
|
220
|
+
yield RunComplete(
|
|
221
|
+
result=self._build_result(
|
|
222
|
+
transcript,
|
|
223
|
+
usage_total,
|
|
224
|
+
run_id,
|
|
225
|
+
"length",
|
|
226
|
+
output=response.message.content or "",
|
|
227
|
+
started=started,
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
assistant = response.message
|
|
233
|
+
if assistant.tool_calls:
|
|
234
|
+
stamped = _stamp_approvals(assistant.tool_calls, self._tool_map)
|
|
235
|
+
assistant = assistant.model_copy(update={"tool_calls": stamped})
|
|
236
|
+
transcript.append(assistant)
|
|
237
|
+
|
|
238
|
+
for tc in assistant.tool_calls or []:
|
|
239
|
+
tool = self._tool_map.get(tc.name)
|
|
240
|
+
yield ToolCallEnd(
|
|
241
|
+
tool_call_id=tc.id,
|
|
242
|
+
name=tc.name,
|
|
243
|
+
args=tc.args,
|
|
244
|
+
requires_approval=tool.requires_approval if tool else False,
|
|
245
|
+
approval=tc.approval,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
if not assistant.tool_calls:
|
|
249
|
+
output = self._final_output(assistant.content or "", run_id)
|
|
250
|
+
yield RunComplete(
|
|
251
|
+
result=self._build_result(
|
|
252
|
+
transcript,
|
|
253
|
+
usage_total,
|
|
254
|
+
run_id,
|
|
255
|
+
"stop",
|
|
256
|
+
output=output,
|
|
257
|
+
started=started,
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
pending, resolved, resolve_events = await self._resolve_unresolved(
|
|
263
|
+
transcript, run_id, emit_tool_results=True
|
|
264
|
+
)
|
|
265
|
+
for event in resolve_events:
|
|
266
|
+
yield event
|
|
267
|
+
if pending:
|
|
268
|
+
yield RunComplete(
|
|
269
|
+
result=self._build_result(
|
|
270
|
+
transcript,
|
|
271
|
+
usage_total,
|
|
272
|
+
run_id,
|
|
273
|
+
"waiting_approval",
|
|
274
|
+
output="",
|
|
275
|
+
started=started,
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
need_hop = resolved
|
|
281
|
+
|
|
282
|
+
raise RuntimeError("agent loop ended without a terminal state")
|
|
283
|
+
|
|
284
|
+
return _gen()
|
|
285
|
+
|
|
286
|
+
async def _adapter_stream_hop(
|
|
287
|
+
self,
|
|
288
|
+
transcript: list[Message],
|
|
289
|
+
usage_total: Usage,
|
|
290
|
+
) -> AsyncIterator[AgentEvent | _StreamHopDone]:
|
|
291
|
+
request = self._build_model_request(transcript)
|
|
292
|
+
text_parts: list[str] = []
|
|
293
|
+
tool_calls: list[ToolCall] = []
|
|
294
|
+
hop_usage = Usage()
|
|
295
|
+
finish: ModelFinish | None = None
|
|
296
|
+
|
|
297
|
+
async for event in self._adapter.stream(request):
|
|
298
|
+
if isinstance(event, ProviderTextDelta):
|
|
299
|
+
text_parts.append(event.delta)
|
|
300
|
+
yield TextDelta(delta=event.delta)
|
|
301
|
+
elif isinstance(event, ProviderToolCallStart):
|
|
302
|
+
yield ToolCallStart(tool_call_id=event.tool_call_id, name=event.name)
|
|
303
|
+
elif isinstance(event, ProviderToolCall):
|
|
304
|
+
tool_calls.append(ToolCall(id=event.tool_call_id, name=event.name, args=event.args))
|
|
305
|
+
elif isinstance(event, ProviderUsage):
|
|
306
|
+
hop_usage = event.usage
|
|
307
|
+
merged = _merge_usage(usage_total, hop_usage)
|
|
308
|
+
yield UsageUpdate(usage=event.usage, usage_total=merged)
|
|
309
|
+
elif isinstance(event, ProviderFinish):
|
|
310
|
+
finish = event.finish
|
|
311
|
+
|
|
312
|
+
if finish is None:
|
|
313
|
+
raise ValueError("adapter stream ended without finish")
|
|
314
|
+
|
|
315
|
+
content = "".join(text_parts) if text_parts else None
|
|
316
|
+
if content == "" and tool_calls:
|
|
317
|
+
content = None
|
|
318
|
+
|
|
319
|
+
message = Message(
|
|
320
|
+
id=str(uuid4()),
|
|
321
|
+
role="assistant",
|
|
322
|
+
content=content,
|
|
323
|
+
tool_calls=tool_calls or None,
|
|
324
|
+
created_at=datetime.now(UTC),
|
|
325
|
+
)
|
|
326
|
+
yield _StreamHopDone(
|
|
327
|
+
response=ModelResponse(message=message, usage=hop_usage, finish=finish)
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def _build_model_request(self, transcript: list[Message]) -> ModelRequest:
|
|
331
|
+
return ModelRequest(
|
|
332
|
+
model=self._model_id,
|
|
333
|
+
system=self.system_prompt,
|
|
334
|
+
messages=list(transcript),
|
|
335
|
+
tools=[
|
|
336
|
+
ToolSchema(
|
|
337
|
+
name=t.name,
|
|
338
|
+
description=t.description,
|
|
339
|
+
parameters=t.input_schema().model_json_schema(),
|
|
340
|
+
)
|
|
341
|
+
for t in self.tools
|
|
342
|
+
],
|
|
343
|
+
temperature=self.temperature,
|
|
344
|
+
top_p=self.top_p,
|
|
345
|
+
max_tokens=self.max_tokens,
|
|
346
|
+
stop=self.stop,
|
|
347
|
+
output_schema=(
|
|
348
|
+
self.output_schema.model_json_schema() if self.output_schema is not None else None
|
|
349
|
+
),
|
|
350
|
+
provider_options=self.provider_options,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
async def _model_hop(self, transcript: list[Message], run_id: str) -> ModelResponse:
|
|
354
|
+
request = self._build_model_request(transcript)
|
|
355
|
+
return await self._adapter.complete(request)
|
|
356
|
+
|
|
357
|
+
async def _resolve_unresolved(
|
|
358
|
+
self,
|
|
359
|
+
transcript: list[Message],
|
|
360
|
+
run_id: str,
|
|
361
|
+
*,
|
|
362
|
+
emit_tool_results: bool = False,
|
|
363
|
+
) -> tuple[bool, bool, list[ToolResult]]:
|
|
364
|
+
still_pending = False
|
|
365
|
+
results_appended = False
|
|
366
|
+
resolve_events: list[ToolResult] = []
|
|
367
|
+
|
|
368
|
+
for _assistant_msg, tc in _unresolved_calls(transcript):
|
|
369
|
+
if tc.approval == "pending":
|
|
370
|
+
still_pending = True
|
|
371
|
+
continue
|
|
372
|
+
|
|
373
|
+
if tc.approval == "denied":
|
|
374
|
+
body = self._append_tool_result(
|
|
375
|
+
transcript,
|
|
376
|
+
tc,
|
|
377
|
+
{"status": "denied", "reason": "User denied this tool call"},
|
|
378
|
+
is_error=False,
|
|
379
|
+
)
|
|
380
|
+
results_appended = True
|
|
381
|
+
if emit_tool_results:
|
|
382
|
+
resolve_events.append(
|
|
383
|
+
ToolResult(
|
|
384
|
+
tool_call_id=tc.id,
|
|
385
|
+
name=tc.name,
|
|
386
|
+
result=body,
|
|
387
|
+
is_error=False,
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
continue
|
|
391
|
+
|
|
392
|
+
tool = self._tool_map.get(tc.name)
|
|
393
|
+
if tool is None:
|
|
394
|
+
raise AgentError("unknown_tool", f"unknown tool: {tc.name}", run_id=run_id)
|
|
395
|
+
|
|
396
|
+
if tc.approval not in (None, "approved"):
|
|
397
|
+
continue
|
|
398
|
+
|
|
399
|
+
schema = tool.input_schema()
|
|
400
|
+
try:
|
|
401
|
+
args = schema.model_validate(tc.args)
|
|
402
|
+
except ValidationError as e:
|
|
403
|
+
body = self._append_tool_result(
|
|
404
|
+
transcript,
|
|
405
|
+
tc,
|
|
406
|
+
{"status": "error", "error": str(e)},
|
|
407
|
+
is_error=True,
|
|
408
|
+
)
|
|
409
|
+
results_appended = True
|
|
410
|
+
if emit_tool_results:
|
|
411
|
+
resolve_events.append(
|
|
412
|
+
ToolResult(
|
|
413
|
+
tool_call_id=tc.id,
|
|
414
|
+
name=tc.name,
|
|
415
|
+
result=body,
|
|
416
|
+
is_error=True,
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
continue
|
|
420
|
+
|
|
421
|
+
ctx = ToolContext(run_id=run_id)
|
|
422
|
+
try:
|
|
423
|
+
result = await tool.execute(args, ctx)
|
|
424
|
+
content: Any = result
|
|
425
|
+
if not isinstance(result, str):
|
|
426
|
+
content = json.dumps(result)
|
|
427
|
+
body = self._append_tool_result(transcript, tc, content, is_error=False)
|
|
428
|
+
is_error = False
|
|
429
|
+
except Exception as e:
|
|
430
|
+
body = self._append_tool_result(
|
|
431
|
+
transcript,
|
|
432
|
+
tc,
|
|
433
|
+
{"status": "error", "error": str(e)},
|
|
434
|
+
is_error=True,
|
|
435
|
+
)
|
|
436
|
+
is_error = True
|
|
437
|
+
results_appended = True
|
|
438
|
+
if emit_tool_results:
|
|
439
|
+
resolve_events.append(
|
|
440
|
+
ToolResult(
|
|
441
|
+
tool_call_id=tc.id,
|
|
442
|
+
name=tc.name,
|
|
443
|
+
result=body,
|
|
444
|
+
is_error=is_error,
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
return still_pending, results_appended, resolve_events
|
|
449
|
+
|
|
450
|
+
def _append_tool_result(
|
|
451
|
+
self,
|
|
452
|
+
transcript: list[Message],
|
|
453
|
+
tc: ToolCall,
|
|
454
|
+
content: Any,
|
|
455
|
+
*,
|
|
456
|
+
is_error: bool,
|
|
457
|
+
) -> str:
|
|
458
|
+
body = json.dumps(content) if isinstance(content, dict) else str(content)
|
|
459
|
+
transcript.append(
|
|
460
|
+
Message(
|
|
461
|
+
id=str(uuid4()),
|
|
462
|
+
role="tool",
|
|
463
|
+
content=body,
|
|
464
|
+
tool_call_id=tc.id,
|
|
465
|
+
tool_name=tc.name,
|
|
466
|
+
tool_is_error=is_error,
|
|
467
|
+
created_at=datetime.now(UTC),
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
return body
|
|
471
|
+
|
|
472
|
+
def _final_output(self, content: str, run_id: str) -> Any:
|
|
473
|
+
if self.output_schema is None:
|
|
474
|
+
return content
|
|
475
|
+
try:
|
|
476
|
+
return self.output_schema.model_validate_json(content)
|
|
477
|
+
except ValidationError as e:
|
|
478
|
+
raise AgentError("invalid_output", str(e), run_id=run_id) from e
|
|
479
|
+
|
|
480
|
+
def _build_result(
|
|
481
|
+
self,
|
|
482
|
+
transcript: list[Message],
|
|
483
|
+
usage: Usage,
|
|
484
|
+
run_id: str,
|
|
485
|
+
finish_reason: FinishReason,
|
|
486
|
+
*,
|
|
487
|
+
output: Any,
|
|
488
|
+
started: float,
|
|
489
|
+
) -> RunResult:
|
|
490
|
+
pending = [tc for _msg, tc in _unresolved_calls(transcript) if tc.approval == "pending"]
|
|
491
|
+
return RunResult(
|
|
492
|
+
run_id=run_id,
|
|
493
|
+
output=output,
|
|
494
|
+
messages=transcript,
|
|
495
|
+
usage=usage,
|
|
496
|
+
tool_calls=_tool_call_records(transcript),
|
|
497
|
+
pending_approvals=pending,
|
|
498
|
+
finish_reason=finish_reason,
|
|
499
|
+
model=self.model,
|
|
500
|
+
latency_ms=int((time.monotonic() - started) * 1000),
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _unresolved_calls(transcript: list[Message]) -> list[tuple[Message, ToolCall]]:
|
|
505
|
+
result_ids = {m.tool_call_id for m in transcript if m.role == "tool" and m.tool_call_id}
|
|
506
|
+
out: list[tuple[Message, ToolCall]] = []
|
|
507
|
+
for msg in transcript:
|
|
508
|
+
if msg.role != "assistant" or not msg.tool_calls:
|
|
509
|
+
continue
|
|
510
|
+
for tc in msg.tool_calls:
|
|
511
|
+
if tc.id not in result_ids:
|
|
512
|
+
out.append((msg, tc))
|
|
513
|
+
return out
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _stamp_approvals(
|
|
517
|
+
tool_calls: list[ToolCall],
|
|
518
|
+
tool_map: dict[str, BaseTool],
|
|
519
|
+
) -> list[ToolCall]:
|
|
520
|
+
stamped: list[ToolCall] = []
|
|
521
|
+
for tc in tool_calls:
|
|
522
|
+
tool = tool_map.get(tc.name)
|
|
523
|
+
if tool is not None and tool.requires_approval and tc.approval is None:
|
|
524
|
+
stamped.append(tc.model_copy(update={"approval": "pending"}))
|
|
525
|
+
else:
|
|
526
|
+
stamped.append(tc)
|
|
527
|
+
return stamped
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _merge_usage(total: Usage, hop: Usage) -> Usage:
|
|
531
|
+
return Usage(
|
|
532
|
+
input_tokens=total.input_tokens + hop.input_tokens,
|
|
533
|
+
output_tokens=total.output_tokens + hop.output_tokens,
|
|
534
|
+
cache_read_tokens=total.cache_read_tokens + hop.cache_read_tokens,
|
|
535
|
+
cache_write_tokens=total.cache_write_tokens + hop.cache_write_tokens,
|
|
536
|
+
raw={**total.raw, **hop.raw},
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _tool_call_records(transcript: list[Message]) -> list[ToolCallRecord]:
|
|
541
|
+
results_by_id: dict[str, Message] = {}
|
|
542
|
+
for msg in transcript:
|
|
543
|
+
if msg.role == "tool" and msg.tool_call_id:
|
|
544
|
+
results_by_id[msg.tool_call_id] = msg
|
|
545
|
+
|
|
546
|
+
records: list[ToolCallRecord] = []
|
|
547
|
+
for msg in transcript:
|
|
548
|
+
if msg.role != "assistant" or not msg.tool_calls:
|
|
549
|
+
continue
|
|
550
|
+
for tc in msg.tool_calls:
|
|
551
|
+
result_msg = results_by_id.get(tc.id)
|
|
552
|
+
records.append(
|
|
553
|
+
ToolCallRecord(
|
|
554
|
+
id=tc.id,
|
|
555
|
+
name=tc.name,
|
|
556
|
+
args=tc.args,
|
|
557
|
+
approval=tc.approval,
|
|
558
|
+
result=result_msg.content if result_msg else None,
|
|
559
|
+
is_error=result_msg.tool_is_error if result_msg else False,
|
|
560
|
+
)
|
|
561
|
+
)
|
|
562
|
+
return records
|
lite_agent_sdk/errors.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AgentError(Exception):
|
|
5
|
+
def __init__(
|
|
6
|
+
self,
|
|
7
|
+
code: Literal[
|
|
8
|
+
"unknown_provider",
|
|
9
|
+
"unknown_tool",
|
|
10
|
+
"invalid_output",
|
|
11
|
+
"missing_api_key",
|
|
12
|
+
],
|
|
13
|
+
message: str,
|
|
14
|
+
run_id: str | None = None,
|
|
15
|
+
) -> None:
|
|
16
|
+
self.code = code
|
|
17
|
+
self.message = message
|
|
18
|
+
self.run_id = run_id
|
|
19
|
+
super().__init__(message)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
|
|
3
|
+
from lite_agent_sdk.providers.anthropic_provider import AnthropicAdapter, create_anthropic_adapter
|
|
4
|
+
from lite_agent_sdk.providers.base import ProviderAdapter
|
|
5
|
+
from lite_agent_sdk.providers.openai_provider import OpenAIAdapter, create_openai_adapter
|
|
6
|
+
|
|
7
|
+
AdapterFactory = Callable[[str | None], ProviderAdapter]
|
|
8
|
+
|
|
9
|
+
_ADAPTER_FACTORIES: dict[str, AdapterFactory] = {
|
|
10
|
+
"openai": create_openai_adapter,
|
|
11
|
+
"anthropic": create_anthropic_adapter,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def create_adapter(provider_name: str, api_key: str | None) -> ProviderAdapter:
|
|
16
|
+
factory = _ADAPTER_FACTORIES.get(provider_name)
|
|
17
|
+
if factory is None:
|
|
18
|
+
raise KeyError(provider_name)
|
|
19
|
+
return factory(api_key)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"_ADAPTER_FACTORIES",
|
|
24
|
+
"AnthropicAdapter",
|
|
25
|
+
"OpenAIAdapter",
|
|
26
|
+
"create_adapter",
|
|
27
|
+
"create_anthropic_adapter",
|
|
28
|
+
"create_openai_adapter",
|
|
29
|
+
]
|