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.
@@ -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
+ ]
@@ -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
@@ -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
+ ]