tether-sdk 0.0.2__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.
tether/harness.py ADDED
@@ -0,0 +1,435 @@
1
+ """Control Plane: Tether Harness, bounded agent loop, live SQLite inbox, and headless spawner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from tether.context import ACT_TOOL_SCHEMA, ContextAssembler, estimate_messages_tokens
13
+ from tether.decorator import ToolSpec, build_tool_spec
14
+ from tether.multiplexer import Multiplexer
15
+ from tether.providers import ModelProvider, resolve_provider
16
+ from tether.sandbox import SandboxPolicy
17
+ from tether.skills import Skill, SkillLoader, make_skill_tool
18
+ from tether.store import SessionRecord, SQLiteStore
19
+ from tether.tools import (
20
+ ALL_BUILTIN_TOOLS,
21
+ make_agent_tool,
22
+ make_bash_tool,
23
+ make_done_tool,
24
+ make_edit_tool,
25
+ make_mem_tool,
26
+ make_web_tool,
27
+ )
28
+
29
+
30
+ def spawn_detached_worker(db_path: Path, session_id: str) -> int:
31
+ """Spawn a detached OS background worker (`python -m tether.cli _worker <session_id>`)."""
32
+ env = dict(os.environ)
33
+ env["TETHER_DB"] = str(db_path)
34
+ proc = subprocess.Popen(
35
+ [sys.executable, "-m", "tether.cli", "_worker", session_id, "--db", str(db_path)],
36
+ stdin=subprocess.DEVNULL,
37
+ stdout=subprocess.DEVNULL,
38
+ stderr=subprocess.DEVNULL,
39
+ start_new_session=True,
40
+ env=env,
41
+ )
42
+ return proc.pid
43
+
44
+
45
+ class Harness:
46
+ """General-purpose LLM-Native Micro-Harness."""
47
+
48
+ def __init__(
49
+ self,
50
+ *,
51
+ model: str | None = None,
52
+ tools: list[str | ToolSpec | Callable[..., Any]] | str | None = None,
53
+ skill: str | Skill | None = None,
54
+ sandbox: SandboxPolicy | None = None,
55
+ db_path: str | Path | None = None,
56
+ max_steps: int = 15,
57
+ max_context_tokens: int = 8192,
58
+ fold_observations_after: int = 2,
59
+ provider: ModelProvider | None = None,
60
+ skill_search_dirs: list[str | Path] | None = None,
61
+ worker_spawner: Callable[[str], int | None] | None = None,
62
+ ) -> None:
63
+ self.model = model or os.environ.get("TETHER_MODEL") or "ollama/qwen3.5:9b"
64
+ self.store = SQLiteStore(db_path=db_path)
65
+ self.sandbox = sandbox or SandboxPolicy()
66
+ self.max_steps = max_steps
67
+ self.max_context_tokens = max_context_tokens
68
+ self.fold_observations_after = fold_observations_after
69
+ self._provider = provider
70
+ self.skill_loader = SkillLoader(search_dirs=skill_search_dirs)
71
+ self._worker_spawner = worker_spawner
72
+
73
+ self._custom_tools: dict[str, ToolSpec] = {}
74
+ self._enabled_tool_names: list[str] = []
75
+
76
+ if isinstance(skill, Skill):
77
+ self._skill: Skill | None = skill
78
+ self.skill_name: str | None = skill.name
79
+ elif isinstance(skill, str) and skill.strip():
80
+ self.skill_name = skill.strip()
81
+ self._skill = self.skill_loader.load(self.skill_name)
82
+ else:
83
+ self._skill = None
84
+ self.skill_name = None
85
+
86
+ if self._skill:
87
+ if model is None and self._skill.model:
88
+ self.model = self._skill.model
89
+ for ct in self._skill.custom_tools:
90
+ self._custom_tools[ct.name] = ct
91
+
92
+ self._configure_initial_tools(tools)
93
+
94
+ def _configure_initial_tools(
95
+ self, tools: list[str | ToolSpec | Callable[..., Any]] | str | None
96
+ ) -> None:
97
+ raw_items: list[str | ToolSpec | Callable[..., Any]] = []
98
+ if tools is None:
99
+ if self._skill and self._skill.tools:
100
+ raw_items.extend(self._skill.tools)
101
+ elif isinstance(tools, str):
102
+ for part in tools.split(","):
103
+ if part.strip():
104
+ raw_items.append(part.strip())
105
+ else:
106
+ raw_items.extend(tools)
107
+
108
+ for item in raw_items:
109
+ if isinstance(item, str):
110
+ name = item.strip()
111
+ if name == "all":
112
+ for b in ALL_BUILTIN_TOOLS:
113
+ if b not in self._enabled_tool_names:
114
+ self._enabled_tool_names.append(b)
115
+ elif name and name != "done" and name not in self._enabled_tool_names:
116
+ self._enabled_tool_names.append(name)
117
+ else:
118
+ self.register(item)
119
+
120
+ def register(self, fn_or_spec: ToolSpec | Callable[..., Any]) -> ToolSpec:
121
+ """Register a custom Python function or ToolSpec with this Harness."""
122
+ if isinstance(fn_or_spec, ToolSpec):
123
+ spec = fn_or_spec
124
+ elif hasattr(fn_or_spec, "__tether_tool__"):
125
+ spec = fn_or_spec.__tether_tool__
126
+ else:
127
+ spec = build_tool_spec(fn_or_spec)
128
+ self._custom_tools[spec.name] = spec
129
+ if spec.name not in self._enabled_tool_names:
130
+ self._enabled_tool_names.append(spec.name)
131
+ return spec
132
+
133
+ def _spawner_for_store(self) -> Callable[[str], int | None]:
134
+ if self._worker_spawner is not None:
135
+ return self._worker_spawner
136
+
137
+ def _default_spawn(session_id: str) -> int | None:
138
+ pid = spawn_detached_worker(self.store.db_path, session_id)
139
+ self.store.update_session(session_id, pid=pid)
140
+ return pid
141
+
142
+ return _default_spawn
143
+
144
+ def build_multiplexer(
145
+ self,
146
+ session_id: str,
147
+ enabled_tools: list[str],
148
+ sandbox: SandboxPolicy,
149
+ model: str,
150
+ ) -> Multiplexer:
151
+ """Construct the session Multiplexer with `done` + strictly opted-in tools."""
152
+ mux = Multiplexer()
153
+ # `done` is always registered first
154
+ mux.register(make_done_tool())
155
+
156
+ names_to_load: list[str] = []
157
+ for t in enabled_tools:
158
+ if t == "all":
159
+ for b in ALL_BUILTIN_TOOLS:
160
+ if b not in names_to_load:
161
+ names_to_load.append(b)
162
+ elif t != "done" and t not in names_to_load:
163
+ names_to_load.append(t)
164
+
165
+ for name in names_to_load:
166
+ if name in self._custom_tools:
167
+ mux.register(self._custom_tools[name])
168
+ elif name == "bash":
169
+ mux.register(make_bash_tool(sandbox))
170
+ elif name == "edit":
171
+ mux.register(make_edit_tool(sandbox))
172
+ elif name == "web":
173
+ mux.register(make_web_tool())
174
+ elif name == "mem":
175
+ mux.register(make_mem_tool(self.store, session_id))
176
+ elif name == "skill":
177
+ mux.register(make_skill_tool(self.skill_loader, mux))
178
+ elif name == "agent":
179
+ mux.register(
180
+ make_agent_tool(
181
+ store=self.store,
182
+ parent_session_id=session_id,
183
+ default_model=model,
184
+ parent_sandbox=sandbox,
185
+ skill_loader=self.skill_loader,
186
+ worker_spawner=self._spawner_for_store(),
187
+ )
188
+ )
189
+ return mux
190
+
191
+ def create_session(self, goal: str, *, parent_id: str | None = None) -> SessionRecord:
192
+ """Create a persisted session row in SQLite without executing it yet."""
193
+ return self.store.create_session(
194
+ goal=goal,
195
+ model=self.model,
196
+ skill=self.skill_name,
197
+ tools=self._enabled_tool_names,
198
+ sandbox_config=self.sandbox.to_dict(),
199
+ parent_id=parent_id,
200
+ max_steps=self.max_steps,
201
+ )
202
+
203
+ def spawn(self, goal: str, *, parent_id: str | None = None) -> str:
204
+ """Create a session and launch it as a detached headless worker, returning session_id."""
205
+ rec = self.create_session(goal, parent_id=parent_id)
206
+ spawner = self._spawner_for_store()
207
+ spawner(rec.id)
208
+ return rec.id
209
+
210
+ async def run(self, goal: str, *, parent_id: str | None = None) -> SessionRecord:
211
+ """Create and run a session inline until completion (`done`) or `max_steps`."""
212
+ rec = self.create_session(goal, parent_id=parent_id)
213
+ return await self.execute_session(rec.id)
214
+
215
+ async def send(
216
+ self,
217
+ session_id: str,
218
+ message: str,
219
+ *,
220
+ headless: bool = False,
221
+ ) -> SessionRecord:
222
+ """Send a message to a session's SQLite inbox; auto-resumes if COMPLETED/FAILED."""
223
+ rec = self.store.get_session(session_id)
224
+ if not rec:
225
+ raise ValueError(f"Session `{session_id}` not found.")
226
+
227
+ self.store.enqueue_inbox(session_id, message)
228
+ if rec.status == "RUNNING":
229
+ return rec
230
+
231
+ # Session was IDLE/COMPLETED/FAILED -> resume it
232
+ if headless:
233
+ self.store.update_session(session_id, status="PENDING")
234
+ spawner = self._spawner_for_store()
235
+ spawner(session_id)
236
+ updated = self.store.get_session(session_id)
237
+ assert updated is not None
238
+ return updated
239
+
240
+ return await self.execute_session(session_id)
241
+
242
+ async def execute_session(self, session_id: str) -> SessionRecord:
243
+ """Execute the bounded agent loop for a persisted session in SQLite."""
244
+ rec = self.store.get_session(session_id)
245
+ if not rec:
246
+ raise ValueError(f"Session `{session_id}` not found.")
247
+
248
+ sandbox = SandboxPolicy.from_dict(rec.sandbox_config)
249
+ skill_obj = self._skill
250
+ if skill_obj is None and rec.skill:
251
+ skill_obj = self.skill_loader.load(rec.skill)
252
+ if skill_obj:
253
+ for ct in skill_obj.custom_tools:
254
+ self._custom_tools.setdefault(ct.name, ct)
255
+
256
+ enabled_tools = list(rec.tools)
257
+ if skill_obj and skill_obj.tools:
258
+ for st in skill_obj.tools:
259
+ if st not in enabled_tools:
260
+ enabled_tools.append(st)
261
+
262
+ mux = self.build_multiplexer(
263
+ session_id=session_id,
264
+ enabled_tools=enabled_tools,
265
+ sandbox=sandbox,
266
+ model=rec.model,
267
+ )
268
+
269
+ provider = self._provider or resolve_provider(rec.model, num_ctx=self.max_context_tokens)
270
+ assembler = ContextAssembler(
271
+ fold_after_turns=self.fold_observations_after,
272
+ max_context_tokens=self.max_context_tokens,
273
+ )
274
+ system_prompt = assembler.build_system_prompt(
275
+ toolbox_index=mux.toolbox_index(),
276
+ skill_instructions=skill_obj.instructions if skill_obj else None,
277
+ )
278
+
279
+ self.store.update_session(session_id, status="RUNNING", pid=os.getpid())
280
+
281
+ step_start = rec.step_count
282
+ step_limit = step_start + rec.max_steps
283
+ last_action_sig: tuple[str, str | None] | None = None
284
+
285
+ try:
286
+ for step in range(step_start + 1, step_limit + 1):
287
+ # 1. Poll SQLite inbox before each reasoning step
288
+ inbox_msgs = self.store.consume_inbox(session_id)
289
+ if inbox_msgs:
290
+ last_action_sig = None
291
+ for imsg in inbox_msgs:
292
+ self.store.add_turn(
293
+ session_id=session_id,
294
+ step=step,
295
+ role="user",
296
+ content=f"[Follow-up instruction]: {imsg}",
297
+ )
298
+
299
+ # 2. Assemble context with ephemeral observation folding
300
+ turns = self.store.get_turns(session_id)
301
+ observations = self.store.list_observations(session_id)
302
+ pins = self.store.get_pins(session_id)
303
+ messages = assembler.assemble(
304
+ system_prompt=system_prompt,
305
+ goal=rec.goal,
306
+ turns=turns,
307
+ observations=observations,
308
+ pins=pins,
309
+ current_step=step,
310
+ )
311
+
312
+ est_tokens = estimate_messages_tokens(messages, [ACT_TOOL_SCHEMA])
313
+ resp = await provider.generate(messages, [ACT_TOOL_SCHEMA])
314
+ actual_tokens = max(est_tokens, resp.prompt_tokens)
315
+ resolved_model = (
316
+ f"ollama/{provider.model_name}"
317
+ if rec.model.startswith("ollama/")
318
+ and not rec.model.endswith(provider.model_name)
319
+ else None
320
+ )
321
+ self.store.update_session(
322
+ session_id,
323
+ model=resolved_model,
324
+ step_count=step,
325
+ peak_tokens=actual_tokens,
326
+ )
327
+
328
+ # 3. If the model emitted plain text without calling `act`, nudge it (Rule 3)
329
+ if resp.action is None:
330
+ self.store.add_turn(
331
+ session_id=session_id,
332
+ step=step,
333
+ role="assistant",
334
+ content=resp.content or "(no output)",
335
+ )
336
+ self.store.add_turn(
337
+ session_id=session_id,
338
+ step=step,
339
+ role="user",
340
+ content=(
341
+ "System reminder: Use `act(cmd, stdin)` to execute a tool command, "
342
+ 'or call `act("done", stdin="<final deliverable>")` to finish.'
343
+ ),
344
+ )
345
+ continue
346
+
347
+ cmd = resp.action.cmd
348
+ stdin = resp.action.stdin
349
+ self.store.add_turn(
350
+ session_id=session_id,
351
+ step=step,
352
+ role="assistant",
353
+ content=resp.content,
354
+ tool_cmd=cmd,
355
+ tool_stdin=stdin,
356
+ )
357
+
358
+ action_sig = (cmd.strip(), stdin)
359
+ if action_sig == last_action_sig and not cmd.strip().startswith("done"):
360
+ stall_msg = (
361
+ f"[LOOP DETECTED] You just ran `{cmd.strip()}` with identical input in the "
362
+ "previous step. Do not repeat the same command—inspect the prior output, "
363
+ 'adjust your arguments, or call `act("done", stdin="...")` to finish.'
364
+ )
365
+ self.store.add_turn(
366
+ session_id=session_id,
367
+ step=step,
368
+ role="tool",
369
+ content=stall_msg,
370
+ )
371
+ continue
372
+ last_action_sig = action_sig
373
+
374
+ dispatch = await mux.execute(cmd, stdin=stdin)
375
+ if dispatch.is_done:
376
+ # Check if any new message arrived in inbox while executing this step
377
+ late_inbox = self.store.consume_inbox(session_id)
378
+ if late_inbox:
379
+ for lmsg in late_inbox:
380
+ self.store.add_turn(
381
+ session_id=session_id,
382
+ step=step,
383
+ role="user",
384
+ content=f"[Follow-up instruction]: {lmsg}",
385
+ )
386
+ continue
387
+
388
+ final_text = dispatch.final_result or ""
389
+ self.store.add_turn(
390
+ session_id=session_id,
391
+ step=step,
392
+ role="tool",
393
+ content=f"[COMPLETED] {final_text}",
394
+ )
395
+ self.store.update_session(
396
+ session_id,
397
+ status="COMPLETED",
398
+ result=final_text,
399
+ step_count=step,
400
+ )
401
+ done_rec = self.store.get_session(session_id)
402
+ assert done_rec is not None
403
+ return done_rec
404
+
405
+ # Record tool observation in SQLite (auto-chunks into FTS5)
406
+ obs = self.store.add_observation(
407
+ session_id=session_id,
408
+ step=step,
409
+ cmd=cmd,
410
+ raw_output=dispatch.output,
411
+ )
412
+ self.store.add_turn(
413
+ session_id=session_id,
414
+ step=step,
415
+ role="tool",
416
+ content=dispatch.output,
417
+ obs_id=obs.id,
418
+ )
419
+
420
+ # Max steps reached without explicit `done`
421
+ self.store.update_session(
422
+ session_id,
423
+ status="FAILED",
424
+ result=f"Max steps ({rec.max_steps}) reached without calling `done`.",
425
+ )
426
+ except Exception as exc:
427
+ self.store.update_session(
428
+ session_id,
429
+ status="FAILED",
430
+ result=f"Worker error: {exc}",
431
+ )
432
+
433
+ final_rec = self.store.get_session(session_id)
434
+ assert final_rec is not None
435
+ return final_rec
tether/multiplexer.py ADDED
@@ -0,0 +1,124 @@
1
+ """Virtual CLI Multiplexer (`act(cmd, stdin)`) with 9B quirk normalization and JIT --help."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from tether.decorator import ToolSpec, build_tool_spec
11
+ from tether.sandbox import SandboxViolationError
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class DispatchResult:
16
+ output: str
17
+ is_done: bool = False
18
+ final_result: str | None = None
19
+ is_error: bool = False
20
+
21
+
22
+ def normalize_cmd(raw_cmd: str) -> str:
23
+ """Normalize common small-model CLI syntax quirks deterministically."""
24
+ s = raw_cmd.strip()
25
+ # Strip wrapping markdown code fences or inline backticks
26
+ if s.startswith("```") and s.endswith("```"):
27
+ lines = s.splitlines()
28
+ if len(lines) >= 2:
29
+ s = "\n".join(lines[1:-1]).strip()
30
+ if len(s) >= 2 and s.startswith("`") and s.endswith("`") and "`" not in s[1:-1]:
31
+ s = s[1:-1].strip()
32
+ # Strip leading shell prompt markers ($ or >)
33
+ if s.startswith("$ "):
34
+ s = s[2:].strip()
35
+ elif s.startswith("> "):
36
+ s = s[2:].strip()
37
+ # Strip redundant leading `tether ` prefix if the model typed `tether web search ...`
38
+ if s.startswith("tether "):
39
+ s = s[len("tether ") :].strip()
40
+ return s
41
+
42
+
43
+ def split_cli(cmd: str) -> list[str]:
44
+ """Split a CLI command string using POSIX shlex with graceful fallback on unclosed quotes."""
45
+ try:
46
+ return shlex.split(cmd, posix=True)
47
+ except ValueError:
48
+ return [tok.strip("\"'") for tok in cmd.split() if tok.strip("\"'")]
49
+
50
+
51
+ class Multiplexer:
52
+ """Routes `act(cmd, stdin)` invocations across registered tools."""
53
+
54
+ def __init__(self) -> None:
55
+ self._tools: dict[str, ToolSpec] = {}
56
+
57
+ def register(self, item: ToolSpec | Callable[..., Any]) -> ToolSpec:
58
+ if isinstance(item, ToolSpec):
59
+ spec = item
60
+ elif hasattr(item, "__tether_tool__"):
61
+ spec = item.__tether_tool__
62
+ else:
63
+ spec = build_tool_spec(item)
64
+ self._tools[spec.name] = spec
65
+ return spec
66
+
67
+ def get_tool(self, name: str) -> ToolSpec | None:
68
+ return self._tools.get(name)
69
+
70
+ @property
71
+ def tool_names(self) -> list[str]:
72
+ return list(self._tools.keys())
73
+
74
+ def toolbox_index(self) -> str:
75
+ """Render the ultra-compact 1-line-per-tool directory for the system prompt."""
76
+ lines = [spec.index_line() for spec in self._tools.values()]
77
+ return "\n".join(lines)
78
+
79
+ async def execute(self, cmd: str, stdin: str | None = None) -> DispatchResult:
80
+ """Execute a virtual CLI command and return a DispatchResult."""
81
+ clean_cmd = normalize_cmd(cmd)
82
+ if not clean_cmd:
83
+ avail = ", ".join(self._tools.keys())
84
+ return DispatchResult(
85
+ output=f"Error: empty command. Available tools: {avail}. Run `<tool> --help`.",
86
+ is_error=True,
87
+ )
88
+
89
+ tokens = split_cli(clean_cmd)
90
+ if not tokens:
91
+ return DispatchResult(output="Error: empty command.", is_error=True)
92
+
93
+ name = tokens[0]
94
+ args = tokens[1:]
95
+
96
+ if name in ("help", "--help", "-h"):
97
+ if args and args[0] in self._tools:
98
+ return DispatchResult(output=self._tools[args[0]].help_text())
99
+ return DispatchResult(
100
+ output=f"Available commands:\n{self.toolbox_index()}\nRun `<cmd> --help` for usage."
101
+ )
102
+
103
+ spec = self._tools.get(name)
104
+ if spec is None:
105
+ avail = ", ".join(self._tools.keys())
106
+ return DispatchResult(
107
+ output=f"Error: unknown command `{name}`. Enabled tools: {avail}.",
108
+ is_error=True,
109
+ )
110
+
111
+ try:
112
+ out = await spec.invoke(args, stdin=stdin)
113
+ except SandboxViolationError as exc:
114
+ return DispatchResult(output=str(exc), is_error=True)
115
+ except Exception as exc:
116
+ return DispatchResult(
117
+ output=f"Error executing `{name}`: {exc}\n{spec.help_text()}",
118
+ is_error=True,
119
+ )
120
+
121
+ if name == "done" and not out.startswith("Error:") and not out.startswith("Usage:"):
122
+ return DispatchResult(output=out, is_done=True, final_result=out)
123
+
124
+ return DispatchResult(output=out, is_error=out.startswith("Error:"))
@@ -0,0 +1,66 @@
1
+ """Model provider resolver and custom SDK provider registry for Tether."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from tether.providers.anthropic import AnthropicProvider
8
+ from tether.providers.base import (
9
+ ActionCall,
10
+ ModelProvider,
11
+ ModelResponse,
12
+ extract_fallback_action,
13
+ parse_tool_arguments,
14
+ )
15
+ from tether.providers.ollama import OllamaProvider
16
+ from tether.providers.openai import OpenAIProvider
17
+
18
+ ProviderFactory = Callable[[str], ModelProvider]
19
+ _CUSTOM_PROVIDERS: dict[str, ProviderFactory] = {}
20
+
21
+
22
+ def register_provider(prefix: str, factory: ProviderFactory) -> None:
23
+ """Register a custom SDK model provider under `<prefix>/<model_name>`."""
24
+ clean = prefix.strip().lower().rstrip("/")
25
+ if not clean:
26
+ raise ValueError("Provider prefix cannot be empty.")
27
+ _CUSTOM_PROVIDERS[clean] = factory
28
+
29
+
30
+ def resolve_provider(model: str, *, num_ctx: int = 8192) -> ModelProvider:
31
+ """Resolve `<provider>/<model_name>` into an async ModelProvider instance."""
32
+ spec = model.strip() or "ollama/qwen3.5:9b"
33
+ if "/" in spec:
34
+ prefix, name = spec.split("/", 1)
35
+ else:
36
+ prefix, name = "ollama", spec
37
+
38
+ provider = prefix.lower()
39
+ if provider in _CUSTOM_PROVIDERS:
40
+ return _CUSTOM_PROVIDERS[provider](name)
41
+ if provider == "ollama":
42
+ return OllamaProvider(model_name=name, num_ctx=num_ctx)
43
+ if provider in ("openai", "openrouter", "vllm", "groq", "deepseek"):
44
+ return OpenAIProvider(model_name=name)
45
+ if provider in ("anthropic", "claude"):
46
+ return AnthropicProvider(model_name=name)
47
+ supported = ["ollama/", "openai/", "anthropic/", *[f"{k}/" for k in _CUSTOM_PROVIDERS]]
48
+ raise ValueError(
49
+ f"Unknown model provider `{prefix}` in `{model}`. "
50
+ f"Supported prefixes: {', '.join(supported)}."
51
+ )
52
+
53
+
54
+ __all__ = [
55
+ "ActionCall",
56
+ "AnthropicProvider",
57
+ "ModelProvider",
58
+ "ModelResponse",
59
+ "OllamaProvider",
60
+ "OpenAIProvider",
61
+ "ProviderFactory",
62
+ "extract_fallback_action",
63
+ "parse_tool_arguments",
64
+ "register_provider",
65
+ "resolve_provider",
66
+ ]