coreouto 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.
coreouto/__init__.py ADDED
@@ -0,0 +1,95 @@
1
+ """coreouto — a minimal, extensible Python agent library.
2
+
3
+ See README.md and docs/ for usage. The public API is re-exported here.
4
+ """
5
+
6
+ from coreouto._types import (
7
+ AgentConfig,
8
+ LLMResponse,
9
+ Message,
10
+ Response,
11
+ ToolCall,
12
+ ToolResult,
13
+ Usage,
14
+ )
15
+ from coreouto._version import __version__
16
+ from coreouto.agent import Agent, MaxIterationsError
17
+ from coreouto.hooks import (
18
+ AFTER_LLM_CALL,
19
+ AFTER_TOOL_CALL,
20
+ BEFORE_LLM_CALL,
21
+ BEFORE_TOOL_CALL,
22
+ ON_FINISH,
23
+ ON_ITERATION,
24
+ ON_USER_INJECTION,
25
+ clear_hooks,
26
+ get_hooks,
27
+ register_hook,
28
+ )
29
+ from coreouto.multi_agent import agent_as_tool, make_delegate_tool
30
+ from coreouto.presets import (
31
+ AgentPreset,
32
+ clear_agent_presets,
33
+ get_agent_preset,
34
+ list_agent_presets,
35
+ register_agent_preset,
36
+ )
37
+ from coreouto.providers import (
38
+ available_providers,
39
+ clear_providers,
40
+ get_provider,
41
+ register_provider,
42
+ )
43
+ from coreouto.settings import CANONICAL_SETTINGS, normalize_provider_config
44
+ from coreouto.sync import call_sync
45
+ from coreouto.tools import (
46
+ Tool,
47
+ clear_tools,
48
+ get_tool,
49
+ list_tools,
50
+ register_tool,
51
+ register_tool_class,
52
+ )
53
+
54
+ __all__ = [
55
+ "AFTER_LLM_CALL",
56
+ "AFTER_TOOL_CALL",
57
+ "BEFORE_LLM_CALL",
58
+ "BEFORE_TOOL_CALL",
59
+ "CANONICAL_SETTINGS",
60
+ "ON_FINISH",
61
+ "ON_ITERATION",
62
+ "ON_USER_INJECTION",
63
+ "Agent",
64
+ "AgentConfig",
65
+ "AgentPreset",
66
+ "LLMResponse",
67
+ "MaxIterationsError",
68
+ "Message",
69
+ "Response",
70
+ "Tool",
71
+ "ToolCall",
72
+ "ToolResult",
73
+ "Usage",
74
+ "__version__",
75
+ "agent_as_tool",
76
+ "available_providers",
77
+ "call_sync",
78
+ "clear_agent_presets",
79
+ "clear_hooks",
80
+ "clear_providers",
81
+ "clear_tools",
82
+ "get_agent_preset",
83
+ "get_hooks",
84
+ "get_provider",
85
+ "get_tool",
86
+ "list_agent_presets",
87
+ "list_tools",
88
+ "make_delegate_tool",
89
+ "normalize_provider_config",
90
+ "register_agent_preset",
91
+ "register_hook",
92
+ "register_provider",
93
+ "register_tool",
94
+ "register_tool_class",
95
+ ]
coreouto/_types.py ADDED
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
6
+
7
+
8
+ class ToolCall(BaseModel):
9
+ model_config = ConfigDict(arbitrary_types_allowed=True)
10
+
11
+ id: str
12
+ name: str
13
+ arguments: dict[str, Any] = Field(default_factory=dict)
14
+
15
+
16
+ class ToolResult(BaseModel):
17
+ model_config = ConfigDict(arbitrary_types_allowed=True)
18
+
19
+ tool_call_id: str
20
+ content: str
21
+ is_error: bool = False
22
+
23
+
24
+ class Usage(BaseModel):
25
+ model_config = ConfigDict(arbitrary_types_allowed=True)
26
+
27
+ prompt_tokens: int
28
+ completion_tokens: int
29
+ total_tokens: int
30
+
31
+ @model_validator(mode="after")
32
+ def _total_matches_sum(self) -> Usage:
33
+ if self.total_tokens != self.prompt_tokens + self.completion_tokens:
34
+ raise ValueError(
35
+ f"total_tokens ({self.total_tokens}) must equal "
36
+ f"prompt_tokens ({self.prompt_tokens}) + "
37
+ f"completion_tokens ({self.completion_tokens})"
38
+ )
39
+ return self
40
+
41
+
42
+ class LLMResponse(BaseModel):
43
+ model_config = ConfigDict(arbitrary_types_allowed=True)
44
+
45
+ content: str | None = None
46
+ tool_calls: list[ToolCall] = Field(default_factory=list)
47
+ usage: Usage | None = None
48
+ raw: Any | None = None
49
+
50
+
51
+ class Message(BaseModel):
52
+ model_config = ConfigDict(arbitrary_types_allowed=True)
53
+
54
+ role: Literal["system", "user", "assistant", "tool"]
55
+ content: str
56
+ tool_calls: list[ToolCall] | None = None
57
+ tool_call_id: str | None = None
58
+ name: str | None = None
59
+
60
+
61
+ class AgentConfig(BaseModel):
62
+ model_config = ConfigDict(arbitrary_types_allowed=True)
63
+
64
+ name: str
65
+ model: str
66
+ provider: str
67
+ system_prompt: str | None = None
68
+ tools: list[str] = Field(default_factory=list)
69
+ max_iterations: int = 50
70
+ provider_config: dict[str, Any] = Field(default_factory=dict)
71
+ provider_passthrough: dict[str, Any] = Field(default_factory=dict)
72
+
73
+
74
+ class Response(BaseModel):
75
+ model_config = ConfigDict(arbitrary_types_allowed=True)
76
+
77
+ content: str
78
+ messages: list[Message]
79
+ iterations: int
80
+ usage: list[Usage] = Field(default_factory=list)
81
+ finish_called: bool = True
82
+ warnings: list[str] = Field(default_factory=list)
coreouto/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Version constant for coreouto."""
2
+
3
+ __version__ = "0.1.0"
coreouto/agent.py ADDED
@@ -0,0 +1,213 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import re
6
+ from typing import Any
7
+
8
+ from coreouto._types import AgentConfig, Message, Response, ToolResult, Usage
9
+ from coreouto.hooks import (
10
+ AFTER_LLM_CALL,
11
+ AFTER_TOOL_CALL,
12
+ BEFORE_LLM_CALL,
13
+ BEFORE_TOOL_CALL,
14
+ ON_FINISH,
15
+ ON_ITERATION,
16
+ ON_USER_INJECTION,
17
+ trigger,
18
+ )
19
+ from coreouto.providers import get_provider
20
+ from coreouto.settings import normalize_provider_config
21
+ from coreouto.tools import get_tool
22
+
23
+ _FINISH_RE = re.compile(r"<finish>(.*?)</finish>", re.DOTALL)
24
+ _FINISH_REMINDER = (
25
+ "Your previous response did not contain a <finish> tag. "
26
+ "You MUST wrap your final user-facing answer in <finish>...</finish> tags. "
27
+ "Anything outside the tags is discarded. "
28
+ "Example: <finish>Your answer here.</finish>"
29
+ )
30
+ _DEFAULT_SYSTEM_PROMPT = (
31
+ "You are an agent. Use tools to gather information, then return your final answer to the user.\n\n"
32
+ "CRITICAL: When you are done, your final user-facing answer MUST be wrapped in <finish>...</finish> tags. "
33
+ "The text inside the tags is what the user will see.\n\n"
34
+ "Example:\n"
35
+ "The capital of France is Paris.\n"
36
+ "<finish>Paris is the capital of France.</finish>\n\n"
37
+ "If you respond with text but no <finish> tags, the loop will continue and you'll be asked to retry."
38
+ )
39
+
40
+
41
+ class MaxIterationsError(Exception):
42
+ pass
43
+
44
+
45
+ class Agent:
46
+ def __init__(self, config: AgentConfig) -> None:
47
+ self.config = config
48
+ self.provider_name = config.provider
49
+ self._pending_user_messages: asyncio.Queue[str] = asyncio.Queue()
50
+
51
+ def inject_user_message(self, content: str) -> None:
52
+ """Queue a user message to be inserted on the next loop iteration.
53
+
54
+ Thread-safe and async-safe: callable from any thread, from another
55
+ async task, or from a hook callback. The message is inserted into
56
+ the conversation at the start of the next iteration and fires the
57
+ `on_user_injection` hook.
58
+ """
59
+ self._pending_user_messages.put_nowait(content)
60
+
61
+ def call_sync(
62
+ self,
63
+ user_message: str,
64
+ *,
65
+ override: AgentConfig | None = None,
66
+ history: list[Message] | None = None,
67
+ ) -> Response:
68
+ from coreouto import sync
69
+
70
+ return sync.call_sync(self, user_message, override=override, history=history)
71
+
72
+ async def call(
73
+ self,
74
+ user_message: str,
75
+ *,
76
+ override: AgentConfig | None = None,
77
+ history: list[Message] | None = None,
78
+ ) -> Response:
79
+ cfg = override or self.config
80
+ provider = get_provider(cfg.provider)
81
+
82
+ resolved_tools: list[Any] = []
83
+ for name in cfg.tools:
84
+ tool = get_tool(name)
85
+ if tool is None:
86
+ raise KeyError(f"tool not registered: {name!r}")
87
+ resolved_tools.append(tool)
88
+
89
+ messages: list[Message] = []
90
+ if cfg.system_prompt:
91
+ messages.append(Message(role="system", content=cfg.system_prompt))
92
+ else:
93
+ messages.append(Message(role="system", content=_DEFAULT_SYSTEM_PROMPT))
94
+ if history:
95
+ messages.extend(history)
96
+ messages.append(Message(role="user", content=user_message))
97
+
98
+ iterations = 0
99
+ all_usage: list[Usage] = []
100
+
101
+ while True:
102
+ await asyncio.sleep(0)
103
+ while not self._pending_user_messages.empty():
104
+ content = self._pending_user_messages.get_nowait()
105
+ injected = Message(role="user", content=content)
106
+ messages.append(injected)
107
+ await trigger(
108
+ ON_USER_INJECTION,
109
+ message=injected,
110
+ messages=messages,
111
+ )
112
+
113
+ iterations += 1
114
+ if iterations > cfg.max_iterations:
115
+ raise MaxIterationsError(
116
+ f"max_iterations ({cfg.max_iterations}) reached without a <finish> tag"
117
+ )
118
+
119
+ await trigger(
120
+ BEFORE_LLM_CALL,
121
+ messages=messages,
122
+ model=cfg.model,
123
+ tools=resolved_tools,
124
+ )
125
+
126
+ normalized = normalize_provider_config(cfg.provider, cfg.provider_config)
127
+ merged = {**normalized, **cfg.provider_passthrough}
128
+ response = await provider.create(
129
+ messages=messages,
130
+ model=cfg.model,
131
+ tools=resolved_tools,
132
+ system_prompt=None,
133
+ **merged,
134
+ )
135
+
136
+ await trigger(AFTER_LLM_CALL, response=response, messages=messages)
137
+ if response.usage:
138
+ all_usage.append(response.usage)
139
+
140
+ assistant_msg = provider.format_assistant_message(response)
141
+ messages.append(assistant_msg)
142
+
143
+ await trigger(
144
+ ON_ITERATION,
145
+ iteration=iterations,
146
+ messages=messages,
147
+ response=response,
148
+ )
149
+
150
+ last_assistant_text = response.content or ""
151
+ match = _FINISH_RE.search(last_assistant_text)
152
+ if match:
153
+ final_answer = match.group(1).strip()
154
+ await trigger(
155
+ ON_FINISH,
156
+ content=final_answer,
157
+ raw_content=last_assistant_text,
158
+ messages=messages,
159
+ iterations=iterations,
160
+ )
161
+ return Response(
162
+ content=final_answer,
163
+ messages=messages,
164
+ iterations=iterations,
165
+ usage=all_usage,
166
+ finish_called=True,
167
+ )
168
+
169
+ if not response.tool_calls:
170
+ messages.append(Message(role="user", content=_FINISH_REMINDER))
171
+ continue
172
+
173
+ for tool_call in response.tool_calls:
174
+ tool = get_tool(tool_call.name)
175
+
176
+ await trigger(
177
+ BEFORE_TOOL_CALL,
178
+ name=tool_call.name,
179
+ arguments=tool_call.arguments,
180
+ )
181
+
182
+ if tool is None:
183
+ result = ToolResult(
184
+ tool_call_id=tool_call.id,
185
+ content=f"tool not found: {tool_call.name}",
186
+ is_error=True,
187
+ )
188
+ else:
189
+ try:
190
+ raw_result = tool.handler(**tool_call.arguments)
191
+ if inspect.iscoroutine(raw_result):
192
+ raw_result = await raw_result
193
+ result_content = str(raw_result)
194
+ result = ToolResult(
195
+ tool_call_id=tool_call.id,
196
+ content=result_content,
197
+ is_error=False,
198
+ )
199
+ except Exception as exc:
200
+ result = ToolResult(
201
+ tool_call_id=tool_call.id,
202
+ content=f"{type(exc).__name__}: {exc}",
203
+ is_error=True,
204
+ )
205
+
206
+ await trigger(
207
+ AFTER_TOOL_CALL,
208
+ name=tool_call.name,
209
+ result=result,
210
+ )
211
+
212
+ tool_msg = provider.format_tool_result(tool_call, result)
213
+ messages.append(tool_msg)
File without changes
@@ -0,0 +1,97 @@
1
+ """Opt-in hook recipes for coreouto.
2
+
3
+ Each factory returns a hook callable ready to be passed to
4
+ ``coreouto.hooks.register_hook``. Recipes that need to share state with the
5
+ caller expose that state by returning it alongside the hook as a tuple
6
+ ``(hook, state)``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ from coreouto._types import LLMResponse, Message, ToolResult, Usage
15
+
16
+
17
+ def token_collection_hook(
18
+ *, sink: list[Usage] | None = None
19
+ ) -> tuple[Callable[..., None], list[Usage]]:
20
+ if sink is None:
21
+ sink = []
22
+
23
+ def hook(response: LLMResponse, **_kwargs: Any) -> None:
24
+ if response.usage is not None:
25
+ sink.append(response.usage)
26
+
27
+ return hook, sink
28
+
29
+
30
+ def auto_summarize_hook(
31
+ *, threshold: int, summarize_fn: Callable[[list[Message]], list[Message]]
32
+ ) -> Callable[..., None]:
33
+ total: list[int] = [0]
34
+
35
+ def hook(
36
+ *, iteration: int, messages: list[Message], response: LLMResponse, **_kwargs: Any
37
+ ) -> None:
38
+ if response.usage is None:
39
+ return
40
+ total[0] += response.usage.total_tokens
41
+ if total[0] >= threshold:
42
+ summarized = summarize_fn(messages)
43
+ messages.clear()
44
+ messages.extend(summarized)
45
+
46
+ return hook
47
+
48
+
49
+ def token_limit_warning_hook(
50
+ *, limit: int, callback: Callable[[Usage], Any] | None = None
51
+ ) -> Callable[..., None]:
52
+ if callback is None:
53
+
54
+ def callback(usage: Usage) -> None:
55
+ print(f"WARNING: token limit {limit} exceeded, current {usage.total_tokens}")
56
+
57
+ def hook(response: LLMResponse, **_kwargs: Any) -> None:
58
+ if response.usage is not None and response.usage.total_tokens > limit:
59
+ callback(response.usage)
60
+
61
+ return hook
62
+
63
+
64
+ def iteration_notification_hook(
65
+ *, every: int = 10, callback: Callable[[int], Any] | None = None
66
+ ) -> Callable[..., None]:
67
+ if callback is None:
68
+
69
+ def callback(iteration: int) -> None:
70
+ print(f"INFO: reached iteration {iteration}")
71
+
72
+ def hook(*, iteration: int, **_kwargs: Any) -> None:
73
+ if iteration % every == 0:
74
+ callback(iteration)
75
+
76
+ return hook
77
+
78
+
79
+ def tool_usage_collection_hook(
80
+ *, sink: list[tuple[str, str, bool]] | None = None
81
+ ) -> tuple[Callable[..., None], list[tuple[str, str, bool]]]:
82
+ if sink is None:
83
+ sink = []
84
+
85
+ def hook(*, name: str, result: ToolResult, **_kwargs: Any) -> None:
86
+ sink.append((name, result.content, result.is_error))
87
+
88
+ return hook, sink
89
+
90
+
91
+ __all__ = [
92
+ "auto_summarize_hook",
93
+ "iteration_notification_hook",
94
+ "token_collection_hook",
95
+ "token_limit_warning_hook",
96
+ "tool_usage_collection_hook",
97
+ ]
coreouto/hooks.py ADDED
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from collections.abc import Callable
5
+ from typing import Any
6
+
7
+ BEFORE_LLM_CALL = "before_llm_call"
8
+ AFTER_LLM_CALL = "after_llm_call"
9
+ BEFORE_TOOL_CALL = "before_tool_call"
10
+ AFTER_TOOL_CALL = "after_tool_call"
11
+ ON_ITERATION = "on_iteration"
12
+ ON_FINISH = "on_finish"
13
+ ON_USER_INJECTION = "on_user_injection"
14
+
15
+ _HOOKS: dict[str, list[Callable[..., Any]]] = {}
16
+
17
+
18
+ def register_hook(event: str, fn: Callable[..., Any]) -> None:
19
+ if event not in _HOOKS:
20
+ _HOOKS[event] = []
21
+ _HOOKS[event].append(fn)
22
+
23
+
24
+ def get_hooks(event: str) -> list[Callable[..., Any]]:
25
+ return list(_HOOKS.get(event, []))
26
+
27
+
28
+ def clear_hooks(event: str | None = None) -> None:
29
+ if event is None:
30
+ _HOOKS.clear()
31
+ else:
32
+ _HOOKS.pop(event, None)
33
+
34
+
35
+ async def trigger(event: str, **kwargs: Any) -> None:
36
+ for fn in _HOOKS.get(event, []):
37
+ if inspect.iscoroutinefunction(fn):
38
+ await fn(**kwargs)
39
+ else:
40
+ fn(**kwargs)
@@ -0,0 +1,108 @@
1
+ """Multi-agent orchestration helpers.
2
+
3
+ `agent_as_tool` wraps a registered agent preset as a callable `Tool`,
4
+ allowing a parent agent to delegate sub-tasks to specialised child agents.
5
+ The returned tool is **not** auto-registered; the caller is responsible
6
+ for wiring it into a parent's tool list (for example, by passing it
7
+ directly to `AgentConfig(tools=[...])` or by wrapping it with
8
+ `register_tool` if a global name is desired).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from coreouto.agent import Agent
14
+ from coreouto.presets import get_agent_preset
15
+ from coreouto.tools import Tool
16
+
17
+ _DEFAULT_DESCRIPTION_TEMPLATE = (
18
+ "Delegate a sub-task to the {preset} agent. Input is the task description."
19
+ )
20
+
21
+ _TASK_PARAMETERS: dict[str, object] = {
22
+ "type": "object",
23
+ "properties": {
24
+ "task": {
25
+ "type": "string",
26
+ "description": "The task description to pass to the sub-agent.",
27
+ }
28
+ },
29
+ "required": ["task"],
30
+ }
31
+
32
+ _DELEGATE_PARAMETERS: dict[str, object] = {
33
+ "type": "object",
34
+ "properties": {
35
+ "agent_name": {
36
+ "type": "string",
37
+ "description": "The name of a registered agent preset to call.",
38
+ },
39
+ "message": {
40
+ "type": "string",
41
+ "description": "The message to pass to the target agent.",
42
+ },
43
+ },
44
+ "required": ["agent_name", "message"],
45
+ }
46
+
47
+ _DEFAULT_DELEGATE_DESCRIPTION = (
48
+ "Call a registered agent by name and pass it a message. "
49
+ "The agent's response is returned as a string."
50
+ )
51
+
52
+
53
+ def agent_as_tool(
54
+ preset_name: str,
55
+ *,
56
+ name: str | None = None,
57
+ description: str | None = None,
58
+ ) -> Tool:
59
+ preset = get_agent_preset(preset_name)
60
+ config = preset.to_config()
61
+ agent = Agent(config)
62
+
63
+ tool_name = name if name is not None else f"call_{preset_name}"
64
+ tool_description = (
65
+ description
66
+ if description is not None
67
+ else _DEFAULT_DESCRIPTION_TEMPLATE.format(preset=preset_name)
68
+ )
69
+
70
+ async def handler(task: str) -> str:
71
+ return (await agent.call(task)).content
72
+
73
+ return Tool(
74
+ name=tool_name,
75
+ description=tool_description,
76
+ parameters=_TASK_PARAMETERS,
77
+ handler=handler,
78
+ )
79
+
80
+
81
+ def make_delegate_tool(
82
+ *,
83
+ name: str = "call_agent",
84
+ description: str | None = None,
85
+ ) -> Tool:
86
+ """Create a tool that dispatches to any registered agent preset by name.
87
+
88
+ The returned tool accepts two arguments:
89
+ - `agent_name: str` — the name of a registered agent preset
90
+ - `message: str` — the message to pass to that agent
91
+
92
+ The handler looks up the preset via `get_agent_preset`, constructs a
93
+ fresh `Agent` from its config, calls it, and returns the response
94
+ content. If the preset is not registered, `KeyError` propagates.
95
+ """
96
+ tool_description = description if description is not None else _DEFAULT_DELEGATE_DESCRIPTION
97
+
98
+ async def delegate(agent_name: str, message: str) -> str:
99
+ preset = get_agent_preset(agent_name)
100
+ agent = Agent(preset.to_config())
101
+ return (await agent.call(message)).content
102
+
103
+ return Tool(
104
+ name=name,
105
+ description=tool_description,
106
+ parameters=_DELEGATE_PARAMETERS,
107
+ handler=delegate,
108
+ )