augagent 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.
augagent/__init__.py ADDED
@@ -0,0 +1,90 @@
1
+ """AugAgent — A multi-agent framework with intuitive DX, powered by Pydantic.
2
+
3
+ Quick Start::
4
+
5
+ from augagent import AugAgent, AugTask, AugTeam, aug_tool, LLMConfig
6
+ from pydantic import Field
7
+
8
+ @aug_tool
9
+ def greet(name: str = Field(description="Person to greet")) -> str:
10
+ \"\"\"Greet someone by name.\"\"\"
11
+ return f"Hello, {name}!"
12
+
13
+ assistant = AugAgent(
14
+ name="Assistant",
15
+ role="Friendly Helper",
16
+ goal="Help users with their requests",
17
+ llm_config=LLMConfig(model="gpt-4o"),
18
+ tools=[greet],
19
+ )
20
+
21
+ task = AugTask(
22
+ description="Greet the user warmly",
23
+ expected_output="A friendly greeting message",
24
+ agent=assistant,
25
+ )
26
+
27
+ team = AugTeam(agents=[assistant], tasks=[task], verbose=True)
28
+ result = team.kickoff()
29
+ """
30
+
31
+ from augagent.agent import Agent, AugAgent
32
+ from augagent.models import (
33
+ AgentConfig,
34
+ ChatCompletion,
35
+ ChatMessage,
36
+ ChatToolCall,
37
+ FunctionCall,
38
+ LLMConfig,
39
+ Message,
40
+ Role,
41
+ TaskResult,
42
+ TaskStatus,
43
+ TokenUsage,
44
+ ToolCall,
45
+ ToolResponse,
46
+ )
47
+ from augagent.task import AugTask, Task
48
+ from augagent.team import AugTeam, Process, Team
49
+ from augagent.telemetry import AgentLogger, get_logger
50
+ from augagent.tools import AugTool, Tool, aug_tool, tool
51
+
52
+ __version__ = "0.1.0"
53
+
54
+ __all__ = [
55
+ # Core orchestration
56
+ "AugAgent",
57
+ "AugTask",
58
+ "AugTeam",
59
+ "Process",
60
+ # Tools
61
+ "AugTool",
62
+ "aug_tool",
63
+ # Backward compatibility aliases
64
+ "Agent",
65
+ "Task",
66
+ "Team",
67
+ "Tool",
68
+ "tool",
69
+ # Models — LLM configuration
70
+ "LLMConfig",
71
+ # Models — chat completion response
72
+ "ChatCompletion",
73
+ "ChatMessage",
74
+ "ChatToolCall",
75
+ "FunctionCall",
76
+ "TokenUsage",
77
+ # Models — internal
78
+ "AgentConfig",
79
+ "Message",
80
+ "Role",
81
+ "TaskResult",
82
+ "TaskStatus",
83
+ "ToolCall",
84
+ "ToolResponse",
85
+ # Telemetry
86
+ "AgentLogger",
87
+ "get_logger",
88
+ # Metadata
89
+ "__version__",
90
+ ]
augagent/agent.py ADDED
@@ -0,0 +1,420 @@
1
+ """AugAgent — the core autonomous entity in augagent.
2
+
3
+ :class:`AugAgent` encapsulates a persona (role, goal, backstory), an
4
+ :class:`~augagent.models.LLMConfig`, and a set of
5
+ :class:`~augagent.tools.AugTool` instances. It implements a **ReAct**
6
+ (Reason + Act) loop that interleaves LLM reasoning with tool execution,
7
+ communicating with the LLM provider over HTTP via :mod:`httpx`.
8
+
9
+ Example::
10
+
11
+ from augagent import AugAgent, aug_tool, LLMConfig
12
+ from pydantic import Field
13
+
14
+ @aug_tool
15
+ def lookup_db(query: str = Field(description="SQL query")) -> str:
16
+ \"\"\"Run a database lookup.\"\"\"
17
+ return "42"
18
+
19
+ agent = AugAgent(
20
+ name="Analyst",
21
+ role="Data Analyst",
22
+ goal="Answer data questions accurately",
23
+ backstory="You have 10 years of experience with SQL and analytics.",
24
+ llm_config=LLMConfig(model="gpt-4o", temperature=0.2),
25
+ tools=[lookup_db],
26
+ )
27
+
28
+ result = await agent.execute("What is the total revenue for Q3?")
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import asyncio
34
+ import json
35
+ import time
36
+ from typing import Any
37
+
38
+ import httpx
39
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
40
+
41
+ from augagent.models import (
42
+ AgentConfig,
43
+ ChatCompletion,
44
+ ChatMessage,
45
+ ChatToolCall,
46
+ LLMConfig,
47
+ TaskResult,
48
+ TaskStatus,
49
+ )
50
+ from augagent.telemetry import get_logger
51
+ from augagent.tools import AugTool
52
+
53
+
54
+ class AugAgent(BaseModel):
55
+ """A single autonomous agent with a built-in ReAct loop.
56
+
57
+ Parameters
58
+ ----------
59
+ name:
60
+ Unique, human-readable identifier for this agent.
61
+ role:
62
+ The agent's persona (e.g. ``"Senior Python Developer"``).
63
+ goal:
64
+ One-sentence objective guiding behaviour.
65
+ backstory:
66
+ Rich context that shapes reasoning style and domain knowledge.
67
+ llm_config:
68
+ Connection and generation settings for the LLM provider.
69
+ tools:
70
+ :class:`~augagent.tools.AugTool` instances the agent may invoke.
71
+ max_iterations:
72
+ Safety cap on ReAct loop iterations (Reason → Act → Observe).
73
+ verbose:
74
+ Enable detailed step-by-step logging.
75
+ allow_delegation:
76
+ Whether this agent may delegate sub-tasks to teammates.
77
+ """
78
+
79
+ model_config = ConfigDict(arbitrary_types_allowed=True)
80
+
81
+ # ── identity ──────────────────────────────────────────────────────────
82
+ name: str = Field(..., min_length=1, max_length=128)
83
+ role: str
84
+ goal: str
85
+ backstory: str = ""
86
+
87
+ # ── LLM configuration ────────────────────────────────────────────────
88
+ llm_config: LLMConfig = Field(default_factory=LLMConfig)
89
+
90
+ # ── capabilities ─────────────────────────────────────────────────────
91
+ tools: list[AugTool] = Field(default_factory=list)
92
+
93
+ # ── behaviour ────────────────────────────────────────────────────────
94
+ max_iterations: int = Field(default=25, ge=1)
95
+ verbose: bool = False
96
+ allow_delegation: bool = False
97
+
98
+ # ── internal state (private, excluded from serialisation) ─────────────
99
+ _message_history: list[dict[str, Any]] = PrivateAttr(default_factory=list)
100
+
101
+ # ══════════════════════════════════════════════════════════════════════
102
+ # PUBLIC API
103
+ # ══════════════════════════════════════════════════════════════════════
104
+
105
+ async def execute(self, prompt: str) -> TaskResult:
106
+ """Run the agent's ReAct loop on the given prompt."""
107
+ logger = get_logger()
108
+ start = time.time()
109
+
110
+ if self.verbose:
111
+ logger.log_info(f"[{self.name}] starting execution with {self.llm_config.model}")
112
+
113
+ try:
114
+ output, usage, iterations = await self._react_loop(prompt, logger)
115
+ elapsed = time.time() - start
116
+
117
+ result = TaskResult(
118
+ task_id="",
119
+ agent_name=self.name,
120
+ status=TaskStatus.COMPLETED,
121
+ output=output,
122
+ token_usage=usage,
123
+ elapsed_seconds=round(elapsed, 3),
124
+ iterations=iterations,
125
+ )
126
+
127
+ if self.verbose:
128
+ logger.log_info(
129
+ f"[{self.name}] completed in {elapsed:.2f}s "
130
+ f"({iterations} iterations, {usage.get('total_tokens', 0)} tokens)"
131
+ )
132
+ return result
133
+
134
+ except Exception as exc:
135
+ elapsed = time.time() - start
136
+ logger.log_error(f"[{self.name}] execution failed: {exc}")
137
+
138
+ return TaskResult(
139
+ task_id="",
140
+ agent_name=self.name,
141
+ status=TaskStatus.FAILED,
142
+ output=f"Agent execution failed: {exc}",
143
+ elapsed_seconds=round(elapsed, 3),
144
+ )
145
+
146
+ # ══════════════════════════════════════════════════════════════════════
147
+ # REACT LOOP (Reason → Act → Observe)
148
+ # ══════════════════════════════════════════════════════════════════════
149
+
150
+ async def _react_loop(
151
+ self,
152
+ prompt: str,
153
+ logger: Any,
154
+ ) -> tuple[str, dict[str, int], int]:
155
+ """Core ReAct loop."""
156
+ messages: list[dict[str, Any]] = [
157
+ {"role": "system", "content": self._build_system_prompt()},
158
+ {"role": "user", "content": prompt},
159
+ ]
160
+ self._message_history = list(messages)
161
+
162
+ total_usage: dict[str, int] = {
163
+ "prompt_tokens": 0,
164
+ "completion_tokens": 0,
165
+ "total_tokens": 0,
166
+ }
167
+
168
+ for iteration in range(1, self.max_iterations + 1):
169
+ if self.verbose:
170
+ logger.log_info(f"[{self.name}] --- iteration {iteration} / {self.max_iterations} ---")
171
+
172
+ # ── REASON: call the LLM ─────────────────────────────────────
173
+ completion = await self._call_llm(messages, logger)
174
+
175
+ if completion.usage:
176
+ total_usage["prompt_tokens"] += completion.usage.prompt_tokens
177
+ total_usage["completion_tokens"] += completion.usage.completion_tokens
178
+ total_usage["total_tokens"] += completion.usage.total_tokens
179
+
180
+ if not completion.choices:
181
+ raise RuntimeError("LLM returned an empty choices array.")
182
+
183
+ assistant_msg = completion.choices[0].message
184
+ messages.append(self._chat_message_to_dict(assistant_msg))
185
+
186
+ # ── Final answer (no tool calls) ─────────────────────────────
187
+ if not assistant_msg.tool_calls:
188
+ final_output = assistant_msg.content or ""
189
+ if self.verbose:
190
+ logger.log_info(f"[{self.name}] final answer ({len(final_output)} chars)")
191
+ self._message_history = list(messages)
192
+ return final_output, total_usage, iteration
193
+
194
+ # ── ACT: execute each tool call ──────────────────────────────
195
+ for tc in assistant_msg.tool_calls:
196
+ logger.log_tool_execution(
197
+ agent_name=self.name,
198
+ tool_name=tc.function.name,
199
+ args=tc.function.arguments[:200]
200
+ )
201
+ tool_output = await self._execute_tool_call(tc, logger)
202
+ messages.append({
203
+ "role": "tool",
204
+ "tool_call_id": tc.id,
205
+ "content": tool_output,
206
+ })
207
+
208
+ self._message_history = list(messages)
209
+
210
+ # ── Max iterations exhausted ─────────────────────────────────────
211
+ logger.log_error(f"[{self.name}] reached max iterations ({self.max_iterations}).")
212
+
213
+ last_content = ""
214
+ for msg in reversed(messages):
215
+ if msg.get("role") == "assistant" and msg.get("content"):
216
+ last_content = msg["content"]
217
+ break
218
+
219
+ return (
220
+ last_content or f"[Max iterations ({self.max_iterations}) reached without a final answer]",
221
+ total_usage,
222
+ self.max_iterations,
223
+ )
224
+
225
+ # ══════════════════════════════════════════════════════════════════════
226
+ # LLM COMMUNICATION (httpx)
227
+ # ══════════════════════════════════════════════════════════════════════
228
+
229
+ async def _call_llm(
230
+ self,
231
+ messages: list[dict[str, Any]],
232
+ logger: Any,
233
+ ) -> ChatCompletion:
234
+ """POST to ``/chat/completions`` with retry + exponential backoff."""
235
+ cfg = self.llm_config
236
+ url = f"{cfg.base_url.rstrip('/')}/chat/completions"
237
+ api_key = cfg.resolve_api_key()
238
+
239
+ headers: dict[str, str] = {
240
+ "Authorization": f"Bearer {api_key}",
241
+ "Content-Type": "application/json",
242
+ **cfg.extra_headers,
243
+ }
244
+
245
+ payload: dict[str, Any] = {
246
+ "model": cfg.model,
247
+ "messages": messages,
248
+ "temperature": cfg.temperature,
249
+ "max_tokens": cfg.max_tokens,
250
+ "top_p": cfg.top_p,
251
+ }
252
+ if cfg.frequency_penalty != 0.0:
253
+ payload["frequency_penalty"] = cfg.frequency_penalty
254
+ if cfg.presence_penalty != 0.0:
255
+ payload["presence_penalty"] = cfg.presence_penalty
256
+ if cfg.stop:
257
+ payload["stop"] = cfg.stop
258
+
259
+ if self.tools:
260
+ payload["tools"] = [t.to_openai_schema() for t in self.tools]
261
+
262
+ last_exc: Exception | None = None
263
+
264
+ for attempt in range(cfg.max_retries + 1):
265
+ try:
266
+ async with httpx.AsyncClient(
267
+ timeout=httpx.Timeout(cfg.timeout),
268
+ ) as client:
269
+ resp = await client.post(url, headers=headers, json=payload)
270
+
271
+ if resp.status_code == 429:
272
+ retry_after = float(resp.headers.get("retry-after", str(2 ** attempt)))
273
+ logger.log_error(f"Rate-limited (429). Retrying in {retry_after:.1f}s")
274
+ await asyncio.sleep(retry_after)
275
+ continue
276
+
277
+ resp.raise_for_status()
278
+ return ChatCompletion.model_validate(resp.json())
279
+
280
+ except httpx.TimeoutException as exc:
281
+ last_exc = exc
282
+ logger.log_error(f"Timeout [attempt {attempt + 1}/{cfg.max_retries + 1}]: {exc}")
283
+ except httpx.HTTPStatusError as exc:
284
+ if exc.response.status_code in {500, 502, 503}:
285
+ last_exc = exc
286
+ logger.log_error(f"Server error {exc.response.status_code} [attempt {attempt + 1}/{cfg.max_retries + 1}]")
287
+ else:
288
+ raise
289
+
290
+ if attempt < cfg.max_retries:
291
+ backoff = min(2 ** attempt, 30)
292
+ await asyncio.sleep(backoff)
293
+
294
+ raise RuntimeError(f"LLM request failed after {cfg.max_retries + 1} attempts: {last_exc}")
295
+
296
+ # ══════════════════════════════════════════════════════════════════════
297
+ # TOOL EXECUTION
298
+ # ══════════════════════════════════════════════════════════════════════
299
+
300
+ async def _execute_tool_call(self, tool_call: ChatToolCall, logger: Any) -> str:
301
+ """Dispatch a single tool call to the matching :class:`AugTool`."""
302
+ tool_map = {t.name: t for t in self.tools}
303
+ tool_impl = tool_map.get(tool_call.function.name)
304
+
305
+ if tool_impl is None:
306
+ error = f"Tool '{tool_call.function.name}' not found."
307
+ logger.log_error(error)
308
+ return json.dumps({"error": error})
309
+
310
+ try:
311
+ arguments = json.loads(tool_call.function.arguments)
312
+ except json.JSONDecodeError as exc:
313
+ error = f"Failed to parse tool arguments: {exc}"
314
+ logger.log_error(error)
315
+ return json.dumps({"error": error})
316
+
317
+ try:
318
+ result = await tool_impl.run(**arguments)
319
+ return result
320
+ except Exception as exc:
321
+ error = f"Tool error ({type(exc).__name__}): {exc}"
322
+ logger.log_error(f"[{tool_call.function.name}] {error}")
323
+ return json.dumps({"error": error})
324
+
325
+ # ══════════════════════════════════════════════════════════════════════
326
+ # PROMPT CONSTRUCTION
327
+ # ══════════════════════════════════════════════════════════════════════
328
+
329
+ def _build_system_prompt(self) -> str:
330
+ """Assemble the system prompt from the agent's persona fields."""
331
+ parts: list[str] = [
332
+ f"You are {self.name}, a {self.role}.",
333
+ f"\nYour goal: {self.goal}",
334
+ ]
335
+
336
+ if self.backstory:
337
+ parts.append(f"\nBackstory: {self.backstory}")
338
+
339
+ if self.tools:
340
+ tool_lines = "\n".join(f" • {t.name} — {t.description}" for t in self.tools)
341
+ parts.append(f"\nYou have access to the following tools:\n{tool_lines}")
342
+ parts.append(
343
+ "\nUse tools when they would help accomplish your goal. "
344
+ "When you have gathered enough information to provide a "
345
+ "final answer, respond directly without making tool calls."
346
+ )
347
+
348
+ return "\n".join(parts)
349
+
350
+ # ══════════════════════════════════════════════════════════════════════
351
+ # SERIALISATION HELPERS
352
+ # ══════════════════════════════════════════════════════════════════════
353
+
354
+ @staticmethod
355
+ def _chat_message_to_dict(msg: ChatMessage) -> dict[str, Any]:
356
+ """Convert a parsed :class:`ChatMessage` back to an API-ready dict."""
357
+ d: dict[str, Any] = {"role": msg.role}
358
+ if msg.content is not None:
359
+ d["content"] = msg.content
360
+ if msg.tool_calls:
361
+ d["tool_calls"] = [
362
+ {
363
+ "id": tc.id,
364
+ "type": tc.type,
365
+ "function": {
366
+ "name": tc.function.name,
367
+ "arguments": tc.function.arguments,
368
+ },
369
+ }
370
+ for tc in msg.tool_calls
371
+ ]
372
+ return d
373
+
374
+ def to_config(self) -> AgentConfig:
375
+ """Export the agent's declarative settings as an :class:`AgentConfig`."""
376
+ return AgentConfig(
377
+ name=self.name,
378
+ role=self.role,
379
+ goal=self.goal,
380
+ backstory=self.backstory,
381
+ llm_config=self.llm_config,
382
+ max_iterations=self.max_iterations,
383
+ allow_delegation=self.allow_delegation,
384
+ verbose=self.verbose,
385
+ )
386
+
387
+ @classmethod
388
+ def from_config(
389
+ cls,
390
+ config: AgentConfig,
391
+ tools: list[AugTool] | None = None,
392
+ ) -> AugAgent:
393
+ """Construct an ``AugAgent`` from a serialised :class:`AgentConfig`."""
394
+ return cls(
395
+ name=config.name,
396
+ role=config.role,
397
+ goal=config.goal,
398
+ backstory=config.backstory,
399
+ llm_config=config.llm_config,
400
+ max_iterations=config.max_iterations,
401
+ allow_delegation=config.allow_delegation,
402
+ verbose=config.verbose,
403
+ tools=tools or [],
404
+ )
405
+
406
+ @property
407
+ def message_history(self) -> list[dict[str, Any]]:
408
+ """Read-only view of the conversation history from the last execution."""
409
+ return list(self._message_history)
410
+
411
+ def __repr__(self) -> str:
412
+ return (
413
+ f"AugAgent(name={self.name!r}, role={self.role!r}, "
414
+ f"model={self.llm_config.model!r}, "
415
+ f"tools={[t.name for t in self.tools]})"
416
+ )
417
+
418
+
419
+ # Backward-compatible alias
420
+ Agent = AugAgent