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 +95 -0
- coreouto/_types.py +82 -0
- coreouto/_version.py +3 -0
- coreouto/agent.py +213 -0
- coreouto/contrib/__init__.py +0 -0
- coreouto/contrib/hooks.py +97 -0
- coreouto/hooks.py +40 -0
- coreouto/multi_agent.py +108 -0
- coreouto/presets.py +85 -0
- coreouto/providers/__init__.py +42 -0
- coreouto/providers/anthropic.py +161 -0
- coreouto/providers/base.py +23 -0
- coreouto/providers/google.py +183 -0
- coreouto/providers/openai.py +157 -0
- coreouto/providers/openai_response.py +188 -0
- coreouto/settings.py +119 -0
- coreouto/sync.py +27 -0
- coreouto/tools.py +157 -0
- coreouto-0.1.0.dist-info/METADATA +101 -0
- coreouto-0.1.0.dist-info/RECORD +22 -0
- coreouto-0.1.0.dist-info/WHEEL +4 -0
- coreouto-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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)
|
coreouto/multi_agent.py
ADDED
|
@@ -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
|
+
)
|