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