talkpipe-agents 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.
- talkpipe_agents/__init__.py +123 -0
- talkpipe_agents/_pytest_plugin.py +35 -0
- talkpipe_agents/chat.py +187 -0
- talkpipe_agents/common_tools.py +497 -0
- talkpipe_agents/hooks.py +81 -0
- talkpipe_agents/models.py +313 -0
- talkpipe_agents/replies.py +116 -0
- talkpipe_agents/script_tools.py +99 -0
- talkpipe_agents/segments.py +201 -0
- talkpipe_agents/support.py +125 -0
- talkpipe_agents/testing.py +266 -0
- talkpipe_agents/tools.py +240 -0
- talkpipe_agents-0.1.0.dist-info/METADATA +268 -0
- talkpipe_agents-0.1.0.dist-info/RECORD +19 -0
- talkpipe_agents-0.1.0.dist-info/WHEEL +5 -0
- talkpipe_agents-0.1.0.dist-info/entry_points.txt +5 -0
- talkpipe_agents-0.1.0.dist-info/licenses/COPYRIGHT +3 -0
- talkpipe_agents-0.1.0.dist-info/licenses/LICENSE +179 -0
- talkpipe_agents-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""talkpipe-agents — agentic segments for talkpipe, built on Strands.
|
|
2
|
+
|
|
3
|
+
A talkpipe segment on the outside, a ``strands.Agent`` on the inside:
|
|
4
|
+
pipelines stay deterministic while individual steps may reason and call
|
|
5
|
+
tools. Installed as a talkpipe plugin, importing talkpipe registers the
|
|
6
|
+
``llmAgent`` segment automatically; the package is also importable
|
|
7
|
+
directly for Python composition.
|
|
8
|
+
|
|
9
|
+
- ``models`` — source/model strings resolved with ``llmPrompt``'s
|
|
10
|
+
semantics (same config fallbacks), decoded to Strands model providers
|
|
11
|
+
through a ``register_agent_model`` registry. ``ollama`` works with the
|
|
12
|
+
base install; ``openai`` (= ``openai_responses``), ``openai_chat``,
|
|
13
|
+
``anthropic`` and ``litellm`` are registered too and import lazily
|
|
14
|
+
behind their pip extras.
|
|
15
|
+
- ``tools`` — a name→tool registry so chatterlang scripts can reference
|
|
16
|
+
tools by name (any other string goes to Strands' own tool loader),
|
|
17
|
+
plus the generic ``ask_user`` and ``think`` tools.
|
|
18
|
+
- ``script_tools`` — chatterlang scripts as tools: ``make_script_tool``
|
|
19
|
+
and the ``scriptTool`` segment, which lets one script define
|
|
20
|
+
documented tooling scripts inline and hand them to ``llmAgent``.
|
|
21
|
+
- ``common_tools`` — a suite of generally useful, bounded tools
|
|
22
|
+
(calculator, time and date, text extraction, read-only file access,
|
|
23
|
+
URL fetching, a notepad kept in the agent's Strands state), all
|
|
24
|
+
registered by name.
|
|
25
|
+
- ``segments`` — the ``llmAgent`` chatterlang segment.
|
|
26
|
+
- ``chat`` — ``AgentChat``, a thin multi-turn wrapper for interactive
|
|
27
|
+
sessions (what a chat UI drives).
|
|
28
|
+
- ``hooks`` — ``AgentHooks``, the Strands hook provider both entry
|
|
29
|
+
points install: per-tool-call progress lines and scrubbing of leaked
|
|
30
|
+
tool-call syntax as messages are stored.
|
|
31
|
+
- ``replies`` — ``ask_and_clean`` (one prompt under Strands ``limits``,
|
|
32
|
+
with the empty-reply retry) and ``agent_limits``.
|
|
33
|
+
- ``support`` — the shared progress convention (``teller``) and reply
|
|
34
|
+
cleanup (``strip_think``, ``strip_tool_leak``).
|
|
35
|
+
- ``testing`` — ``ScriptedModel`` and ``scripted_source``: a Strands
|
|
36
|
+
``Model`` that replays scripted tool calls and answers, so an
|
|
37
|
+
application tests its agent steps offline; the ``scripted_model``
|
|
38
|
+
pytest fixture is installed with the package as a pytest plugin.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
|
|
42
|
+
from importlib.metadata import version as _version
|
|
43
|
+
|
|
44
|
+
from .chat import AgentChat
|
|
45
|
+
from .common_tools import (
|
|
46
|
+
calculator,
|
|
47
|
+
clear_notes,
|
|
48
|
+
current_time,
|
|
49
|
+
days_between,
|
|
50
|
+
delete_note,
|
|
51
|
+
fetch_url,
|
|
52
|
+
list_files,
|
|
53
|
+
read_file,
|
|
54
|
+
read_notes,
|
|
55
|
+
regex_extract,
|
|
56
|
+
save_note,
|
|
57
|
+
search_files,
|
|
58
|
+
summarize_notes,
|
|
59
|
+
update_note,
|
|
60
|
+
)
|
|
61
|
+
from .hooks import AgentHooks
|
|
62
|
+
from .models import agent_model_sources, build_agent_model, register_agent_model
|
|
63
|
+
from .replies import agent_limits, ask_and_clean
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
__version__ = _version("talkpipe-agents")
|
|
67
|
+
except _PackageNotFoundError: # pragma: no cover - only when run from an unbuilt tree
|
|
68
|
+
__version__ = "0.0.0+unknown"
|
|
69
|
+
from .script_tools import ScriptTool, make_script_tool
|
|
70
|
+
from .segments import LLMAgent
|
|
71
|
+
from .support import strip_think, strip_tool_leak, teller
|
|
72
|
+
from .tools import (
|
|
73
|
+
UnknownAgentToolError,
|
|
74
|
+
agent_tool_names,
|
|
75
|
+
make_ask_user,
|
|
76
|
+
register_agent_tool,
|
|
77
|
+
resolve_agent_tools,
|
|
78
|
+
think,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
__all__ = [
|
|
82
|
+
"AgentChat",
|
|
83
|
+
"AgentHooks",
|
|
84
|
+
"LLMAgent",
|
|
85
|
+
"ScriptTool",
|
|
86
|
+
"UnknownAgentToolError",
|
|
87
|
+
"__version__",
|
|
88
|
+
"agent_limits",
|
|
89
|
+
"agent_model_sources",
|
|
90
|
+
"agent_tool_names",
|
|
91
|
+
"ask_and_clean",
|
|
92
|
+
"build_agent_model",
|
|
93
|
+
"calculator",
|
|
94
|
+
"clear_notes",
|
|
95
|
+
"current_time",
|
|
96
|
+
"days_between",
|
|
97
|
+
"delete_note",
|
|
98
|
+
"fetch_url",
|
|
99
|
+
"list_files",
|
|
100
|
+
"make_ask_user",
|
|
101
|
+
"make_script_tool",
|
|
102
|
+
"read_file",
|
|
103
|
+
"read_notes",
|
|
104
|
+
"regex_extract",
|
|
105
|
+
"register_agent_model",
|
|
106
|
+
"register_agent_tool",
|
|
107
|
+
"resolve_agent_tools",
|
|
108
|
+
"save_note",
|
|
109
|
+
"search_files",
|
|
110
|
+
"strip_think",
|
|
111
|
+
"strip_tool_leak",
|
|
112
|
+
"summarize_notes",
|
|
113
|
+
"teller",
|
|
114
|
+
"think",
|
|
115
|
+
"update_note",
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def initialize_plugin() -> None:
|
|
120
|
+
"""Talkpipe plugin hook. Importing this module has already
|
|
121
|
+
registered the ``llmAgent`` segment with talkpipe's registry (the
|
|
122
|
+
decorators run at import), so there is nothing further to do —
|
|
123
|
+
the hook exists to make the plugin contract explicit."""
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""pytest plugin, auto-loaded through the ``pytest11`` entry point when
|
|
2
|
+
this package is installed: the ``scripted_model`` fixture.
|
|
3
|
+
|
|
4
|
+
Only pytest imports this module; nothing in the package does. A project
|
|
5
|
+
that does not want the fixture runs ``pytest -p no:talkpipe_agents``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from .testing import ScriptedModel, scripted_source
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def scripted_model() -> Iterator[ScriptedModel]:
|
|
19
|
+
"""A fresh talkpipe_agents.testing.ScriptedModel, registered as agent
|
|
20
|
+
model source "scripted" for this test. Script the model's turns with
|
|
21
|
+
.call(tool, **args) and .say(text), run your segment or chat with
|
|
22
|
+
source="scripted", then assert on the answer and on .tool_results.
|
|
23
|
+
|
|
24
|
+
Example::
|
|
25
|
+
|
|
26
|
+
def test_agent_uses_my_tool(scripted_model):
|
|
27
|
+
scripted_model.call("lookup_ticket", ticket_id="T-1").say("Not urgent.")
|
|
28
|
+
step = LLMAgent(source="scripted", model="any", tools=["lookup_ticket"])
|
|
29
|
+
assert list(step(["How urgent is T-1?"])) == ["Not urgent."]
|
|
30
|
+
assert scripted_model.tool_results == [
|
|
31
|
+
("lookup_ticket", "success", "Printer on fire, third floor")
|
|
32
|
+
]
|
|
33
|
+
"""
|
|
34
|
+
with scripted_source() as model:
|
|
35
|
+
yield model
|
talkpipe_agents/chat.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""``AgentChat`` — one tool-using conversation, for interactive use.
|
|
2
|
+
|
|
3
|
+
The thin Python-side counterpart of the ``llmAgent`` segment: a chat UI
|
|
4
|
+
creates one ``AgentChat`` per conversation and calls ``ask()`` per user
|
|
5
|
+
message; the Strands agent keeps the history and may call its tools on
|
|
6
|
+
every turn. Memory management is Strands' (sliding window by default;
|
|
7
|
+
pass ``summarizing=True`` for LLM-summarized compaction of long
|
|
8
|
+
conversations, akin to talkpipe's ``memory_mode="summary_llm"``).
|
|
9
|
+
Each ``ask()`` is bounded by the same Strands ``limits`` as the segment:
|
|
10
|
+
``max_turns`` plus the soft ``max_output_tokens`` / ``max_total_tokens``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from collections.abc import Callable, Iterable
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from strands import Agent
|
|
20
|
+
from strands.agent.conversation_manager import SummarizingConversationManager
|
|
21
|
+
|
|
22
|
+
from .hooks import AgentHooks
|
|
23
|
+
from .models import build_agent_model
|
|
24
|
+
from .replies import agent_limits, ask_and_clean
|
|
25
|
+
from .support import teller
|
|
26
|
+
from .tools import resolve_agent_tools, skills_plugin
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# Shown when even the plain-prose retry yielded no answer text; an
|
|
31
|
+
# interactive chat must never come back with silence.
|
|
32
|
+
NO_ANSWER = "(the model produced no answer text — try asking again or rephrasing)"
|
|
33
|
+
# Shown when a turn/token limit ended the exchange before any answer text:
|
|
34
|
+
# the cause is the configured budget, not the model, so say so.
|
|
35
|
+
LIMIT_REACHED = (
|
|
36
|
+
"(the agent stopped at its {limit} limit ({value}) before answering — "
|
|
37
|
+
"raise the limit or ask a smaller question)"
|
|
38
|
+
)
|
|
39
|
+
# Shown when a tool raised a Strands interrupt (it wants a human's input)
|
|
40
|
+
# and the exchange ended on it: the next ``ask`` answers it.
|
|
41
|
+
INTERRUPTED = (
|
|
42
|
+
"(the agent paused for human input by {name}: {reason} — your next "
|
|
43
|
+
"message answers it; or leave that tool out)"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AgentChat:
|
|
48
|
+
"""One conversation. Create a new instance to start over.
|
|
49
|
+
|
|
50
|
+
Keyword arguments:
|
|
51
|
+
|
|
52
|
+
- ``source`` / ``model``: the provider (``ollama``, ``openai``,
|
|
53
|
+
``openai_responses``, ``openai_chat``, ``anthropic``, ``litellm``,
|
|
54
|
+
or one added with
|
|
55
|
+
``register_agent_model``) and the model on it. As for talkpipe's
|
|
56
|
+
``llmPrompt``, both fall back to the configured defaults
|
|
57
|
+
(``TALKPIPE_DEFAULT_MODEL_SOURCE`` / ``TALKPIPE_DEFAULT_MODEL_NAME``).
|
|
58
|
+
- ``system_prompt``: the agent's system prompt.
|
|
59
|
+
- ``tools``: names from the ``register_agent_tool`` registry, Strands
|
|
60
|
+
tool paths (``"module:function"``), ``@strands.tool`` objects, or
|
|
61
|
+
plain functions (wrapped as tools).
|
|
62
|
+
- ``skills``: agent skills, loaded by Strands' ``AgentSkills`` plugin:
|
|
63
|
+
a folder of skill subdirectories (each holding a ``SKILL.md``), one
|
|
64
|
+
skill directory, a ``SKILL.md`` path, or an ``https://`` URL, singly
|
|
65
|
+
or as a list. The skills are listed in the system prompt and a
|
|
66
|
+
``skills`` tool loads one's full instructions on demand.
|
|
67
|
+
- ``temperature``: sampling temperature, when the provider supports it.
|
|
68
|
+
- ``model_params``: provider request parameters, a dict (or its JSON
|
|
69
|
+
text) such as ``{"reasoning_effort": "none"}``; the provider's API
|
|
70
|
+
defines the keys, and Ollama takes them as its ``options``.
|
|
71
|
+
- ``max_turns``: model calls per ``ask()`` (a turn may hold several
|
|
72
|
+
tool calls; ``None`` = no cap; default 8); ``max_output_tokens`` /
|
|
73
|
+
``max_total_tokens``: soft token budgets per ``ask()`` (``None`` =
|
|
74
|
+
no cap). Hitting one ends the exchange with a visible notice, and
|
|
75
|
+
``last_result.stop_reason`` says which (``"limit_turns"``, …). A
|
|
76
|
+
tool that raises a Strands interrupt (human input required) also
|
|
77
|
+
ends the exchange with a notice; ``last_result.interrupts`` holds it,
|
|
78
|
+
and the next ``ask()`` resumes the agent with that message as the
|
|
79
|
+
human's answer.
|
|
80
|
+
- ``summarizing``: LLM-summarized compaction of long conversations
|
|
81
|
+
instead of Strands' default sliding window.
|
|
82
|
+
- ``progress``: a one-line-message callable for ``agent → tool(args)``
|
|
83
|
+
progress lines; defaults to this module's logger at INFO.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
source: str | None = None,
|
|
90
|
+
model: str | None = None,
|
|
91
|
+
system_prompt: str = "You are a helpful assistant.",
|
|
92
|
+
tools: Iterable[Any] = (),
|
|
93
|
+
skills: str | Iterable[str] | None = None,
|
|
94
|
+
temperature: float | None = None,
|
|
95
|
+
model_params: dict[str, Any] | str | None = None,
|
|
96
|
+
max_turns: int | None = 8,
|
|
97
|
+
max_output_tokens: int | None = None,
|
|
98
|
+
max_total_tokens: int | None = None,
|
|
99
|
+
summarizing: bool = False,
|
|
100
|
+
progress: Callable[[str], None] | None = None,
|
|
101
|
+
):
|
|
102
|
+
self.max_turns = max_turns
|
|
103
|
+
self.max_output_tokens = max_output_tokens
|
|
104
|
+
self.max_total_tokens = max_total_tokens
|
|
105
|
+
self.limits = agent_limits(max_turns, max_output_tokens, max_total_tokens)
|
|
106
|
+
self.tell = teller(progress, logger)
|
|
107
|
+
self.last_result: Any = None
|
|
108
|
+
manager = SummarizingConversationManager() if summarizing else None
|
|
109
|
+
self._agent = Agent(
|
|
110
|
+
model=build_agent_model(
|
|
111
|
+
source, model, temperature=temperature, params=model_params
|
|
112
|
+
),
|
|
113
|
+
tools=resolve_agent_tools(tools),
|
|
114
|
+
plugins=skills_plugin(skills),
|
|
115
|
+
system_prompt=system_prompt,
|
|
116
|
+
callback_handler=None,
|
|
117
|
+
conversation_manager=manager,
|
|
118
|
+
hooks=[AgentHooks(self.tell)],
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def agent(self) -> Agent:
|
|
123
|
+
"""The underlying Strands agent (its ``state``, ``messages``,
|
|
124
|
+
``tool_registry`` …) for hosts that need more than ``ask``."""
|
|
125
|
+
return self._agent
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def messages(self) -> list:
|
|
129
|
+
"""The conversation so far, as Strands messages."""
|
|
130
|
+
return self._agent.messages
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def tool_names(self) -> list[str]:
|
|
134
|
+
"""The names of the tools this conversation was given, sorted."""
|
|
135
|
+
return sorted(self._agent.tool_names)
|
|
136
|
+
|
|
137
|
+
def _resume_or(self, question: str) -> Any:
|
|
138
|
+
"""*question* as the agent's input: when the previous exchange
|
|
139
|
+
ended in a tool's interrupt that is still pending, the responses
|
|
140
|
+
that resume it with *question* as the human's answer; otherwise
|
|
141
|
+
*question* itself."""
|
|
142
|
+
state = getattr(self._agent, "_interrupt_state", None)
|
|
143
|
+
pending = getattr(self.last_result, "interrupts", None) or ()
|
|
144
|
+
if not (pending and getattr(state, "activated", False)):
|
|
145
|
+
return question
|
|
146
|
+
return [
|
|
147
|
+
{"interruptResponse": {"interruptId": i.id, "response": question}}
|
|
148
|
+
for i in pending
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
def ask(self, question: str) -> str:
|
|
152
|
+
"""Send one user message and return the agent's reply text.
|
|
153
|
+
|
|
154
|
+
Blocks while the agent runs (calling tools as it sees fit, within
|
|
155
|
+
the limits). The reply is cleaned of reasoning blocks and leaked
|
|
156
|
+
tool-call syntax; when a limit ended the turn before any answer,
|
|
157
|
+
or the model produced none, a short notice is returned instead
|
|
158
|
+
of an empty string. ``last_result`` holds the Strands
|
|
159
|
+
``AgentResult`` of this exchange afterwards. A blank question
|
|
160
|
+
returns ``""`` without calling the model. When the previous
|
|
161
|
+
exchange ended in a tool's interrupt (human input required), the
|
|
162
|
+
message is delivered as the human's answer and the agent resumes
|
|
163
|
+
where it paused instead of starting a new turn.
|
|
164
|
+
"""
|
|
165
|
+
if not isinstance(question, str):
|
|
166
|
+
raise TypeError(
|
|
167
|
+
"AgentChat.ask() takes the user's message as a str, got "
|
|
168
|
+
f"{type(question).__name__}"
|
|
169
|
+
)
|
|
170
|
+
question = question.strip()
|
|
171
|
+
if not question:
|
|
172
|
+
return ""
|
|
173
|
+
self.last_result, answer = ask_and_clean(
|
|
174
|
+
self._agent, self._resume_or(question), limits=self.limits, tell=self.tell
|
|
175
|
+
)
|
|
176
|
+
if answer:
|
|
177
|
+
return answer
|
|
178
|
+
stop_reason = str(self.last_result.stop_reason)
|
|
179
|
+
if stop_reason.startswith("limit_"):
|
|
180
|
+
key = stop_reason.removeprefix("limit_")
|
|
181
|
+
return LIMIT_REACHED.format(limit=key, value=(self.limits or {}).get(key))
|
|
182
|
+
interrupts = getattr(self.last_result, "interrupts", None) or []
|
|
183
|
+
if interrupts:
|
|
184
|
+
return "\n".join(
|
|
185
|
+
INTERRUPTED.format(name=i.name, reason=i.reason) for i in interrupts
|
|
186
|
+
)
|
|
187
|
+
return NO_ANSWER
|