deepagents-talon 0.0.1__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,68 @@
1
+ """Local runtime host for long-running Deep Agents.
2
+
3
+ Talon is an experimental runtime and is subject to change or removal at any time.
4
+ """
5
+
6
+ from deepagents_talon._version import __version__
7
+ from deepagents_talon.config import TalonConfig
8
+ from deepagents_talon.cron import (
9
+ CronJob,
10
+ CronJobError,
11
+ CronJobStore,
12
+ CronOrigin,
13
+ CronSchedule,
14
+ CronTools,
15
+ PersistentCronScheduler,
16
+ )
17
+ from deepagents_talon.host import TalonHost
18
+ from deepagents_talon.interfaces import (
19
+ AgentRequest,
20
+ AgentResult,
21
+ AgentRuntime,
22
+ ChannelAdapter,
23
+ ChannelMedia,
24
+ ChannelMessage,
25
+ ChannelStatus,
26
+ CronScheduler,
27
+ ToolApprovalDecision,
28
+ ToolApprovalHandler,
29
+ ToolApprovalRequest,
30
+ )
31
+ from deepagents_talon.runtime import DeepAgentRuntime, EchoAgentRuntime, RuntimeAgentComponents
32
+ from deepagents_talon.speech import (
33
+ DEFAULT_LOCAL_VOICE_TRANSCRIPTION_MODEL,
34
+ LocalParakeetVoiceTranscriber,
35
+ OpenAIVoiceTranscriber,
36
+ VoiceTranscriber,
37
+ )
38
+
39
+ __all__ = [
40
+ "DEFAULT_LOCAL_VOICE_TRANSCRIPTION_MODEL",
41
+ "AgentRequest",
42
+ "AgentResult",
43
+ "AgentRuntime",
44
+ "ChannelAdapter",
45
+ "ChannelMedia",
46
+ "ChannelMessage",
47
+ "ChannelStatus",
48
+ "CronJob",
49
+ "CronJobError",
50
+ "CronJobStore",
51
+ "CronOrigin",
52
+ "CronSchedule",
53
+ "CronScheduler",
54
+ "CronTools",
55
+ "DeepAgentRuntime",
56
+ "EchoAgentRuntime",
57
+ "LocalParakeetVoiceTranscriber",
58
+ "OpenAIVoiceTranscriber",
59
+ "PersistentCronScheduler",
60
+ "RuntimeAgentComponents",
61
+ "TalonConfig",
62
+ "TalonHost",
63
+ "ToolApprovalDecision",
64
+ "ToolApprovalHandler",
65
+ "ToolApprovalRequest",
66
+ "VoiceTranscriber",
67
+ "__version__",
68
+ ]
@@ -0,0 +1,218 @@
1
+ """Command line entry point for the Talon runtime host.
2
+
3
+ Talon is an experimental runtime and is subject to change or removal at any time.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import asyncio
10
+ import importlib
11
+ import logging
12
+ import os
13
+ import sys
14
+ from typing import TYPE_CHECKING
15
+
16
+ from deepagents_talon.channels.whatsapp import WhatsAppChannel, WhatsAppChannelConfig
17
+ from deepagents_talon.config import TalonConfig
18
+ from deepagents_talon.cron import CronJobStore, PersistentCronScheduler
19
+ from deepagents_talon.data_lifecycle import cleanup_sensitive_state
20
+ from deepagents_talon.fleet import FleetAgentComponents, load_fleet_agent_components
21
+ from deepagents_talon.host import TalonHost
22
+ from deepagents_talon.mcp import load_mcp_tools, print_mcp_config_paths
23
+ from deepagents_talon.runtime import (
24
+ DeepAgentRuntime,
25
+ EchoAgentRuntime,
26
+ RuntimeAgentComponents,
27
+ )
28
+ from deepagents_talon.speech import build_voice_transcriber
29
+
30
+ if TYPE_CHECKING:
31
+ from collections.abc import Sequence
32
+
33
+ from deepagents_talon.cron import CronJob
34
+ from deepagents_talon.interfaces import ChannelAdapter
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ def main() -> None:
40
+ """Run the Talon host with the placeholder runtime."""
41
+ parser = argparse.ArgumentParser(description="Run the Deep Agents Talon host.")
42
+ parser.add_argument(
43
+ "--once",
44
+ action="store_true",
45
+ help="Start and stop immediately after bootstrapping the host.",
46
+ )
47
+ parser.add_argument(
48
+ "--whatsapp",
49
+ action="store_true",
50
+ help="Attach the WhatsApp channel adapter.",
51
+ )
52
+ subparsers = parser.add_subparsers(dest="command")
53
+ _add_mcp_parsers(subparsers)
54
+ args = parser.parse_args()
55
+
56
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s:%(message)s")
57
+
58
+ config = TalonConfig.from_env()
59
+ if args.command == "mcp":
60
+ sys.exit(asyncio.run(_run_mcp_command(args, config)))
61
+
62
+ cron_factory = CronJobStore
63
+ cron_store = cron_factory(assistant_id=config.assistant_id, cron_dir=config.cron_dir)
64
+ config.ensure_home()
65
+ cleanup_sensitive_state(config=config, cron_store=cron_store)
66
+
67
+ channels = _channels(config, enabled=args.whatsapp)
68
+ host = TalonHost(
69
+ config=config,
70
+ agent=asyncio.run(_agent_runtime(config, cron_store)),
71
+ channels=channels,
72
+ voice_transcriber=build_voice_transcriber(config),
73
+ )
74
+ if channels:
75
+ host.scheduler = PersistentCronScheduler(
76
+ store=cron_store,
77
+ run_job=host.run_scheduled_job,
78
+ deliver_result=lambda job, text: _deliver_cron_result(host, channels, job, text),
79
+ )
80
+
81
+ if args.once:
82
+ asyncio.run(_run_once(host))
83
+ return
84
+
85
+ asyncio.run(host.run_until_stopped())
86
+
87
+
88
+ def _add_mcp_parsers(
89
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
90
+ ) -> None:
91
+ mcp = subparsers.add_parser("mcp", help="Manage MCP servers")
92
+ mcp_sub = mcp.add_subparsers(dest="mcp_command")
93
+
94
+ mcp_sub.add_parser("config", help="Show MCP config discovery paths")
95
+
96
+ login = mcp_sub.add_parser("login", help="Run OAuth login for an MCP server")
97
+ login.add_argument("server", help="Server name from mcpServers")
98
+ login.add_argument("--mcp-config", dest="config_path", default=None)
99
+
100
+
101
+ async def _agent_runtime(
102
+ config: TalonConfig,
103
+ cron_store: CronJobStore,
104
+ ) -> EchoAgentRuntime | DeepAgentRuntime:
105
+ env = _runtime_env(config)
106
+ if config.fleet_dir is not None:
107
+ fleet_dir = config.fleet_dir
108
+ components = await load_fleet_agent_components(fleet_dir, env=env)
109
+ runtime_components = _runtime_components_from_fleet(config, components)
110
+
111
+ async def reload_fleet_components() -> RuntimeAgentComponents:
112
+ refreshed = await load_fleet_agent_components(fleet_dir, env=env)
113
+ return _runtime_components_from_fleet(config, refreshed)
114
+
115
+ return DeepAgentRuntime(
116
+ model=runtime_components.model,
117
+ tools=runtime_components.tools,
118
+ system_prompt=runtime_components.system_prompt,
119
+ subagents=runtime_components.subagents,
120
+ skills=runtime_components.skills,
121
+ middleware=runtime_components.middleware,
122
+ interrupt_on=runtime_components.interrupt_on,
123
+ cron_store=cron_store,
124
+ env=env,
125
+ reload_agent_components=reload_fleet_components,
126
+ )
127
+
128
+ if config.model is None:
129
+ return EchoAgentRuntime()
130
+
131
+ mcp = await load_mcp_tools(config)
132
+ for server in mcp.servers:
133
+ if server.error is not None:
134
+ logger.warning("MCP server %s failed: %s", server.name, server.error)
135
+ else:
136
+ logger.info("MCP server %s loaded %d tool(s)", server.name, len(server.tools))
137
+ return DeepAgentRuntime(
138
+ model=config.model,
139
+ tools=mcp.tools,
140
+ assistant_dir=config.manifest_dir,
141
+ cron_store=cron_store,
142
+ env=env,
143
+ )
144
+
145
+
146
+ def _runtime_components_from_fleet(
147
+ config: TalonConfig,
148
+ components: FleetAgentComponents,
149
+ ) -> RuntimeAgentComponents:
150
+ return RuntimeAgentComponents(
151
+ model=config.model or components.model,
152
+ tools=components.tools,
153
+ system_prompt=components.system_prompt,
154
+ subagents=components.subagents,
155
+ skills=components.skills,
156
+ middleware=components.middleware,
157
+ interrupt_on=components.interrupt_on,
158
+ )
159
+
160
+
161
+ async def _run_mcp_command(args: argparse.Namespace, config: TalonConfig) -> int:
162
+ if args.mcp_command == "config":
163
+ print_mcp_config_paths(config)
164
+ return 0
165
+ if args.mcp_command == "login":
166
+ return await _run_mcp_login(args)
167
+ print("Specify an MCP command: config or login", file=sys.stderr) # noqa: T201
168
+ return 2
169
+
170
+
171
+ async def _run_mcp_login(args: argparse.Namespace) -> int:
172
+ try:
173
+ module = importlib.import_module("deepagents_code.mcp_commands")
174
+ except ImportError:
175
+ print( # noqa: T201
176
+ "MCP login requires deepagents-code to be installed in this environment.",
177
+ file=sys.stderr,
178
+ )
179
+ return 1
180
+ run_mcp_login = module.run_mcp_login
181
+ return await run_mcp_login(server=args.server, config_path=args.config_path)
182
+
183
+
184
+ async def _run_once(host: TalonHost) -> None:
185
+ await host.start()
186
+ await host.stop()
187
+
188
+
189
+ def _channels(config: TalonConfig, *, enabled: bool) -> tuple[ChannelAdapter, ...]:
190
+ if not enabled and config.env.get("DEEPAGENTS_TALON_WHATSAPP_ENABLED", "").lower() not in {
191
+ "1",
192
+ "true",
193
+ "yes",
194
+ }:
195
+ return ()
196
+ return (WhatsAppChannel(WhatsAppChannelConfig.from_talon_config(config)),)
197
+
198
+
199
+ def _runtime_env(config: TalonConfig) -> dict[str, str]:
200
+ values = dict(os.environ)
201
+ values.update(config.env)
202
+ return values
203
+
204
+
205
+ async def _deliver_cron_result(
206
+ host: TalonHost,
207
+ channels: Sequence[ChannelAdapter],
208
+ job: CronJob,
209
+ text: str,
210
+ ) -> None:
211
+ for channel in channels:
212
+ if job.origin.channel is None or (await channel.status()).provider == job.origin.channel:
213
+ await host.deliver_scheduled_result(channel, job, text)
214
+ return
215
+
216
+
217
+ if __name__ == "__main__":
218
+ main()
@@ -0,0 +1,3 @@
1
+ """Version marker managed by release-please."""
2
+
3
+ __version__ = "0.0.1"
@@ -0,0 +1,22 @@
1
+ """Channel integrations for Talon.
2
+
3
+ Talon is an experimental runtime and is subject to change or removal at any time.
4
+ """
5
+
6
+ from deepagents_talon.channels.base import (
7
+ ChannelExposure,
8
+ ChannelMediaError,
9
+ ExposureMode,
10
+ chunk_text,
11
+ format_markdown_for_channel,
12
+ validate_media,
13
+ )
14
+
15
+ __all__ = [
16
+ "ChannelExposure",
17
+ "ChannelMediaError",
18
+ "ExposureMode",
19
+ "chunk_text",
20
+ "format_markdown_for_channel",
21
+ "validate_media",
22
+ ]
@@ -0,0 +1,192 @@
1
+ """Reusable channel policy and formatting helpers.
2
+
3
+ Talon is an experimental runtime and is subject to change or removal at any time.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import fnmatch
9
+ import mimetypes
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from enum import StrEnum
13
+ from typing import TYPE_CHECKING
14
+
15
+ from deepagents_talon.interfaces import ChannelMedia, ChannelMessage
16
+ from deepagents_talon.media import resolve_bounded_media_path
17
+
18
+ if TYPE_CHECKING:
19
+ from pathlib import Path
20
+
21
+ MAX_TEXT_CHARS = 4096
22
+ MAX_IMAGE_BYTES = 16 * 1024 * 1024
23
+ MAX_VIDEO_BYTES = 64 * 1024 * 1024
24
+
25
+ _LINK_PATTERN = re.compile(r"\[([^\]]+)]\(([^)]+)\)")
26
+ _HEADING_PATTERN = re.compile(r"^#{1,6}\s+", flags=re.MULTILINE)
27
+ _BOLD_PATTERN = re.compile(r"\*\*([^*]+)\*\*|__([^_]+)__")
28
+ _ITALIC_PATTERN = re.compile(r"(?<!\*)\*([^*\n]+)\*(?!\*)|_([^_\n]+)_")
29
+
30
+
31
+ class ExposureMode(StrEnum):
32
+ """Who may trigger a channel-backed agent."""
33
+
34
+ SELF = "self"
35
+ ALLOWLIST = "allowlist"
36
+ OPEN = "open"
37
+
38
+
39
+ class ChannelMediaError(ValueError):
40
+ """Raised when outbound media cannot be sent safely."""
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class ChannelExposure:
45
+ """Inbound exposure policy shared by channel adapters.
46
+
47
+ Args:
48
+ mode: Trigger policy for inbound messages.
49
+ operator_id: Channel-specific id for the operator's own account.
50
+ conversations: Conversation ids allowed in allowlist mode.
51
+ mention_patterns: Glob-style patterns that may allow a message by text.
52
+ """
53
+
54
+ mode: ExposureMode = ExposureMode.SELF
55
+ operator_id: str | None = None
56
+ conversations: frozenset[str] = field(default_factory=frozenset)
57
+ mention_patterns: tuple[str, ...] = ()
58
+
59
+ def allows(self, message: ChannelMessage) -> bool:
60
+ """Return whether an inbound message may trigger the agent.
61
+
62
+ Args:
63
+ message: Inbound message from a channel adapter.
64
+
65
+ Returns:
66
+ `True` when the message passes this exposure policy.
67
+ """
68
+ if self.mode == ExposureMode.OPEN:
69
+ return True
70
+ if self.mode == ExposureMode.SELF:
71
+ return _is_self_message(message, self.operator_id)
72
+ return message.conversation_id in self.conversations or _matches_text(
73
+ message.text,
74
+ self.mention_patterns,
75
+ )
76
+
77
+
78
+ def format_markdown_for_channel(text: str) -> str:
79
+ """Convert common Markdown into conservative WhatsApp-compatible text.
80
+
81
+ Args:
82
+ text: Markdown text returned by the agent.
83
+
84
+ Returns:
85
+ Text with common Markdown constructs mapped to WhatsApp formatting.
86
+ """
87
+ value = _HEADING_PATTERN.sub("", text)
88
+ value = _LINK_PATTERN.sub(r"\1 (\2)", value)
89
+ value = _ITALIC_PATTERN.sub(lambda match: f"_{match.group(1) or match.group(2)}_", value)
90
+ return _BOLD_PATTERN.sub(lambda match: f"*{match.group(1) or match.group(2)}*", value)
91
+
92
+
93
+ def chunk_text(text: str, *, limit: int = MAX_TEXT_CHARS) -> list[str]:
94
+ """Split outbound text into channel-sized chunks.
95
+
96
+ Args:
97
+ text: Text to split.
98
+ limit: Maximum characters per returned chunk.
99
+
100
+ Returns:
101
+ Non-empty chunks no longer than `limit`.
102
+
103
+ Raises:
104
+ ValueError: If `limit` is not positive.
105
+ """
106
+ if limit < 1:
107
+ msg = "chunk limit must be positive"
108
+ raise ValueError(msg)
109
+
110
+ chunks: list[str] = []
111
+ remaining = text
112
+ while len(remaining) > limit:
113
+ split = _split_index(remaining, limit)
114
+ chunk = remaining[:split].rstrip()
115
+ chunks.append(chunk or remaining[:limit])
116
+ remaining = remaining[split:].lstrip()
117
+ if remaining:
118
+ chunks.append(remaining)
119
+ return chunks
120
+
121
+
122
+ def validate_media(media: ChannelMedia, *, root: Path | None = None) -> ChannelMedia:
123
+ """Validate outbound media path, type, and size.
124
+
125
+ Args:
126
+ media: Media payload to validate.
127
+ root: Optional directory that must contain the media after symlink
128
+ resolution.
129
+
130
+ Returns:
131
+ The validated media payload.
132
+
133
+ Raises:
134
+ ChannelMediaError: If the file is missing, unsupported, or too large.
135
+ """
136
+ try:
137
+ path = (
138
+ resolve_bounded_media_path(media.path, root, require_relative=False)
139
+ if root is not None
140
+ else media.path.expanduser()
141
+ )
142
+ except ValueError as exc:
143
+ msg = str(exc)
144
+ raise ChannelMediaError(msg) from exc
145
+ if not path.is_file():
146
+ msg = f"media file does not exist: {path}"
147
+ raise ChannelMediaError(msg)
148
+
149
+ detected = _media_type(path)
150
+ if detected != media.media_type:
151
+ msg = f"media file type {detected!r} does not match requested type {media.media_type!r}"
152
+ raise ChannelMediaError(msg)
153
+
154
+ limit = MAX_IMAGE_BYTES if media.media_type == "image" else MAX_VIDEO_BYTES
155
+ size = path.stat().st_size
156
+ if size > limit:
157
+ msg = f"{media.media_type} media is too large: {size} bytes exceeds {limit}"
158
+ raise ChannelMediaError(msg)
159
+
160
+ return ChannelMedia(path=path, media_type=media.media_type, caption=media.caption)
161
+
162
+
163
+ def _is_self_message(message: ChannelMessage, operator_id: str | None) -> bool:
164
+ if message.metadata.get("from_self") is True:
165
+ return True
166
+ return operator_id is not None and message.sender_id == operator_id
167
+
168
+
169
+ def _matches_text(text: str, patterns: tuple[str, ...]) -> bool:
170
+ return any(fnmatch.fnmatchcase(text, pattern) for pattern in patterns)
171
+
172
+
173
+ def _split_index(text: str, limit: int) -> int:
174
+ window = text[:limit]
175
+ for delimiter in ("\n\n", "\n", " "):
176
+ index = window.rfind(delimiter)
177
+ if index > 0:
178
+ return index + len(delimiter)
179
+ return limit
180
+
181
+
182
+ def _media_type(path: Path) -> str:
183
+ mime, _ = mimetypes.guess_type(path)
184
+ if mime is None:
185
+ msg = f"unsupported media file type: {path}"
186
+ raise ChannelMediaError(msg)
187
+ if mime.startswith("image/"):
188
+ return "image"
189
+ if mime.startswith("video/"):
190
+ return "video"
191
+ msg = f"unsupported media mime type: {mime}"
192
+ raise ChannelMediaError(msg)