agentkai 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.
Files changed (70) hide show
  1. agentkai/__init__.py +54 -0
  2. agentkai/agent.py +331 -0
  3. agentkai/browser.py +631 -0
  4. agentkai/channels/README.md +117 -0
  5. agentkai/channels/__init__.py +88 -0
  6. agentkai/channels/core.py +507 -0
  7. agentkai/channels/discord.py +126 -0
  8. agentkai/channels/examples/whatsapp-sidecar/package.json +1 -0
  9. agentkai/channels/examples/whatsapp-sidecar/server.js +74 -0
  10. agentkai/channels/static/chat.html +106 -0
  11. agentkai/channels/telegram.py +121 -0
  12. agentkai/channels/webchat.py +182 -0
  13. agentkai/channels/whatsapp.py +130 -0
  14. agentkai/cli.py +505 -0
  15. agentkai/dashboard/API.md +84 -0
  16. agentkai/dashboard/__init__.py +35 -0
  17. agentkai/dashboard/server.py +535 -0
  18. agentkai/dashboard/static/app.js +478 -0
  19. agentkai/dashboard/static/img/logo.gif +0 -0
  20. agentkai/dashboard/static/index.html +66 -0
  21. agentkai/dashboard/static/style.css +246 -0
  22. agentkai/devices/README.md +66 -0
  23. agentkai/devices/__init__.py +455 -0
  24. agentkai/events.py +178 -0
  25. agentkai/goals.py +509 -0
  26. agentkai/mcp.py +515 -0
  27. agentkai/media.py +616 -0
  28. agentkai/memory/__init__.py +192 -0
  29. agentkai/permissions.py +175 -0
  30. agentkai/providers.py +495 -0
  31. agentkai/scheduler/__init__.py +485 -0
  32. agentkai/skills.py +374 -0
  33. agentkai/skills_bundle/__init__.py +9 -0
  34. agentkai/skills_bundle/_common.py +58 -0
  35. agentkai/skills_bundle/_http.py +116 -0
  36. agentkai/skills_bundle/github/SKILL.md +35 -0
  37. agentkai/skills_bundle/github/tools.py +230 -0
  38. agentkai/skills_bundle/gmail/SKILL.md +41 -0
  39. agentkai/skills_bundle/gmail/tools.py +195 -0
  40. agentkai/skills_bundle/google_calendar/SKILL.md +38 -0
  41. agentkai/skills_bundle/google_calendar/tools.py +211 -0
  42. agentkai/skills_bundle/health/SKILL.md +53 -0
  43. agentkai/skills_bundle/health/tools.py +279 -0
  44. agentkai/skills_bundle/image_search/SKILL.md +40 -0
  45. agentkai/skills_bundle/image_search/tools.py +62 -0
  46. agentkai/skills_bundle/media/SKILL.md +62 -0
  47. agentkai/skills_bundle/media/tools.py +9 -0
  48. agentkai/skills_bundle/payments/SKILL.md +55 -0
  49. agentkai/skills_bundle/payments/tools.py +129 -0
  50. agentkai/skills_bundle/places/SKILL.md +47 -0
  51. agentkai/skills_bundle/places/tools.py +153 -0
  52. agentkai/skills_bundle/shopping/SKILL.md +65 -0
  53. agentkai/skills_bundle/shopping/tools.py +131 -0
  54. agentkai/skills_bundle/spotify/SKILL.md +38 -0
  55. agentkai/skills_bundle/spotify/tools.py +194 -0
  56. agentkai/skills_bundle/travel/SKILL.md +68 -0
  57. agentkai/skills_bundle/travel/tools.py +191 -0
  58. agentkai/skills_bundle/voice/SKILL.md +62 -0
  59. agentkai/skills_bundle/voice/tools.py +9 -0
  60. agentkai/subagents.py +386 -0
  61. agentkai/tools/__init__.py +519 -0
  62. agentkai/voice.py +574 -0
  63. agentkai/widgets/PROTOCOL.md +114 -0
  64. agentkai/widgets/__init__.py +247 -0
  65. agentkai-0.1.0.dist-info/METADATA +139 -0
  66. agentkai-0.1.0.dist-info/RECORD +70 -0
  67. agentkai-0.1.0.dist-info/WHEEL +5 -0
  68. agentkai-0.1.0.dist-info/entry_points.txt +2 -0
  69. agentkai-0.1.0.dist-info/licenses/LICENSE +176 -0
  70. agentkai-0.1.0.dist-info/top_level.txt +1 -0
agentkai/__init__.py ADDED
@@ -0,0 +1,54 @@
1
+ """agentkai: model-agnostic personal AI agent."""
2
+ from .agent import Agent, RunResult
3
+ from .events import Event, EventLog, list_runs, replay, summarize
4
+ from .permissions import Action, PermissionGate, cli_ask
5
+ from .providers import (
6
+ ALIASES,
7
+ LLMClient,
8
+ LLMMessage,
9
+ ProviderConfig,
10
+ ToolCall,
11
+ normalize_tool_calls,
12
+ probe_capabilities,
13
+ resolve_fallbacks,
14
+ resolve_model,
15
+ )
16
+ from .scheduler import Job, Scheduler
17
+ from .subagents import (
18
+ ChildRun,
19
+ SubagentManager,
20
+ default_manager,
21
+ readonly_subset,
22
+ scoped_registry,
23
+ )
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ __all__ = [
28
+ "Agent",
29
+ "RunResult",
30
+ "Event",
31
+ "EventLog",
32
+ "list_runs",
33
+ "replay",
34
+ "summarize",
35
+ "Action",
36
+ "PermissionGate",
37
+ "cli_ask",
38
+ "ALIASES",
39
+ "LLMClient",
40
+ "LLMMessage",
41
+ "ProviderConfig",
42
+ "ToolCall",
43
+ "normalize_tool_calls",
44
+ "probe_capabilities",
45
+ "resolve_fallbacks",
46
+ "resolve_model",
47
+ "Job",
48
+ "Scheduler",
49
+ "ChildRun",
50
+ "SubagentManager",
51
+ "default_manager",
52
+ "readonly_subset",
53
+ "scoped_registry",
54
+ ]
agentkai/agent.py ADDED
@@ -0,0 +1,331 @@
1
+ """Hardened ReAct agent loop: think -> tool-call -> observe -> repeat.
2
+
3
+ Provider-agnostic: works with any model behind :class:`LLMClient`, as long
4
+ as it supports function calling (Claude, Gemini, GPT, most others; small
5
+ local models vary — see research/ARCHITECTURE.md).
6
+
7
+ Hardening over the v0 scaffold:
8
+ - streaming responses with incremental text deltas (``on_text`` callback)
9
+ - tool calls normalized to one :class:`ToolCall` dataclass across providers
10
+ - per-step timeout, total run timeout, cooperative cancellation
11
+ - context-window budget: oldest tool outputs are truncated first, then
12
+ compacted into a short summary
13
+ - max-iteration guard
14
+ - permission gate (allow/ask/deny) consulted before every tool call
15
+ - every step recorded to an append-only JSONL event log
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import threading
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Callable
24
+
25
+ import litellm
26
+
27
+ from .events import Event, EventLog
28
+ from .permissions import Action, PermissionGate
29
+ from .providers import LLMClient, ProviderConfig, ToolCall
30
+ from .tools import default_registry
31
+
32
+ SYSTEM_PROMPT = """You are agentkai, a personal AI assistant running on the \
33
+ user's own machine. You have tools: use them to get things done instead of \
34
+ guessing. Be concise, honest about uncertainty, and never invent file contents, \
35
+ URLs, or results — read them with your tools first."""
36
+
37
+ # Rough token estimate when the provider can't count (chars/4 is the
38
+ # standard heuristic; ARCHITECTURE.md notes provider counters are 10-30% off
39
+ # for non-OpenAI models, so this only drives compaction, never hard limits).
40
+ CHARS_PER_TOKEN = 4
41
+
42
+
43
+ @dataclass
44
+ class RunResult:
45
+ run_id: str
46
+ text: str
47
+ status: str # done | max_iterations | timeout | cancelled | error
48
+ iterations: int
49
+ events_path: Path
50
+ error: str = ""
51
+
52
+
53
+ def estimate_tokens(messages: list[dict], model: str) -> int:
54
+ """Best-effort token count for a message list.
55
+
56
+ Tries LiteLLM's counter first; falls back to chars/4. Used only for
57
+ context-budget compaction — never for billing or hard provider limits.
58
+ """
59
+ try:
60
+ return int(litellm.token_counter(model=model, messages=messages))
61
+ except Exception:
62
+ total = 0
63
+ for m in messages:
64
+ total += len(str(m.get("content", "")))
65
+ for tc in m.get("tool_calls", []) or []:
66
+ fn = (tc.get("function", {}) if isinstance(tc, dict)
67
+ else getattr(tc, "function", None))
68
+ if isinstance(fn, dict):
69
+ total += len(str(fn.get("arguments", "")))
70
+ elif fn is not None:
71
+ total += len(str(getattr(fn, "arguments", "")))
72
+ return total // CHARS_PER_TOKEN
73
+
74
+
75
+ class Agent:
76
+ """The hardened agent loop."""
77
+
78
+ def __init__(
79
+ self,
80
+ model: str = "claude",
81
+ system_prompt: str = SYSTEM_PROMPT,
82
+ registry=None,
83
+ gate: PermissionGate | None = None,
84
+ client: LLMClient | None = None,
85
+ config: ProviderConfig | None = None,
86
+ max_iterations: int = 25,
87
+ step_timeout: float = 180,
88
+ total_timeout: float = 1200,
89
+ tool_timeout: float = 300,
90
+ max_context_tokens: int = 100_000,
91
+ events_root: str | Path | None = None,
92
+ on_text: Callable[[str], None] | None = None,
93
+ on_event: Callable[[Event], None] | None = None,
94
+ **client_kwargs,
95
+ ):
96
+ self.system_prompt = system_prompt
97
+ self.registry = registry or default_registry()
98
+ self.gate = gate or PermissionGate()
99
+ self.config = config or ProviderConfig.load()
100
+ self.client = client or LLMClient(model, config=self.config,
101
+ **client_kwargs)
102
+ self.model_name = self.client.model
103
+ self.max_iterations = max_iterations
104
+ self.step_timeout = step_timeout
105
+ self.total_timeout = total_timeout
106
+ self.tool_timeout = tool_timeout
107
+ self.max_context_tokens = max_context_tokens
108
+ self.events_root = events_root
109
+ self.on_text = on_text
110
+ self.on_event = on_event
111
+ self.messages: list[dict] = [{"role": "system", "content": system_prompt}]
112
+
113
+ # -- public API ------------------------------------------------------
114
+
115
+ def run(self, prompt: str,
116
+ cancel_event: threading.Event | None = None,
117
+ on_text: Callable[[str], None] | None = None) -> RunResult:
118
+ """Run the ReAct loop to completion. Returns a RunResult."""
119
+ cancel = cancel_event or threading.Event()
120
+ emit = on_text or self.on_text
121
+ log = EventLog(root=self.events_root, on_event=self.on_event)
122
+ deadline = time.monotonic() + self.total_timeout
123
+ self.messages.append({"role": "user", "content": prompt})
124
+ log.record("run_start", model=self.model_name, prompt=prompt,
125
+ max_iterations=self.max_iterations)
126
+
127
+ def cancelled() -> bool:
128
+ return cancel.is_set() or time.monotonic() >= deadline
129
+
130
+ status, final_text, error = "done", "", ""
131
+ iterations = 0
132
+ try:
133
+ for i in range(self.max_iterations):
134
+ iterations = i + 1
135
+ if cancel.is_set():
136
+ status = "cancelled"
137
+ break
138
+ if time.monotonic() >= deadline:
139
+ status = "timeout"
140
+ error = (f"total run timeout ({self.total_timeout}s) "
141
+ "exceeded")
142
+ break
143
+ self._enforce_budget()
144
+ remaining = max(1.0, deadline - time.monotonic())
145
+ step_to = min(self.step_timeout, remaining)
146
+ try:
147
+ msg = self._llm_step(step_to, cancel, emit)
148
+ except TimeoutError as exc:
149
+ status = "timeout"
150
+ error = str(exc)
151
+ break
152
+ log.record("llm_message", text=msg.text,
153
+ tool_calls=[{"id": tc.id, "name": tc.name,
154
+ "arguments": tc.arguments}
155
+ for tc in msg.tool_calls])
156
+ # (text deltas were already streamed via on_text during the step)
157
+ if not msg.tool_calls:
158
+ final_text = msg.text
159
+ status = "done"
160
+ break
161
+ self.messages.append({
162
+ "role": "assistant",
163
+ "content": msg.text,
164
+ "tool_calls": [
165
+ {"id": tc.id, "type": "function",
166
+ "function": {"name": tc.name,
167
+ "arguments": tc.raw_arguments
168
+ or "{}"}}
169
+ for tc in msg.tool_calls],
170
+ })
171
+ for tc in msg.tool_calls:
172
+ if cancelled():
173
+ status = "cancelled" if cancel.is_set() else "timeout"
174
+ break
175
+ self._exec_tool(tc, log, cancel)
176
+ else:
177
+ continue
178
+ break
179
+ else:
180
+ status = "max_iterations"
181
+ error = (f"stopped after {self.max_iterations} iterations "
182
+ "without a final answer")
183
+ final_text = error
184
+ except Exception as exc: # noqa: BLE001 - run must always report
185
+ status = "error"
186
+ error = f"{type(exc).__name__}: {exc}"
187
+ final_text = error
188
+ finally:
189
+ log.record("run_end", status=status, final_text=final_text[:2000],
190
+ error=error, iterations=iterations)
191
+ events_path = log.path
192
+ log.close()
193
+ return RunResult(run_id=log.run_id, text=final_text, status=status,
194
+ iterations=iterations, events_path=events_path,
195
+ error=error)
196
+
197
+ def chat(self, text: str) -> str:
198
+ """Non-streaming convenience: run one prompt, return final text."""
199
+ return self.run(text).text
200
+
201
+ # -- one LLM step with timeout ----------------------------------------
202
+
203
+ def _llm_step(self, timeout: float, cancel: threading.Event,
204
+ emit: Callable[[str], None] | None):
205
+ """Run client.generate in a worker thread; bound by ``timeout``.
206
+
207
+ The worker is a daemon: on timeout the agent moves on (the stuck
208
+ network read is abandoned, not leaked into the loop).
209
+ """
210
+ box: dict = {}
211
+
212
+ def _target():
213
+ try:
214
+ box["msg"] = self.client.generate(
215
+ self.messages, tools=self.registry.schemas(),
216
+ on_delta=emit, cancel_event=cancel)
217
+ except Exception as exc: # noqa: BLE001
218
+ box["exc"] = exc
219
+
220
+ t = threading.Thread(target=_target, daemon=True)
221
+ t.start()
222
+ t.join(timeout)
223
+ if t.is_alive():
224
+ raise TimeoutError(f"LLM step timed out after {timeout:.0f}s")
225
+ if "exc" in box:
226
+ # Preserve cancellation/timeout semantics from the client.
227
+ raise box["exc"]
228
+ if "msg" not in box:
229
+ raise RuntimeError("LLM step produced no response")
230
+ return box["msg"]
231
+
232
+ # -- tool execution with permission gate --------------------------------
233
+
234
+ def _exec_tool(self, tc: ToolCall, log: EventLog,
235
+ cancel: threading.Event) -> None:
236
+ log.record("tool_call", id=tc.id, name=tc.name,
237
+ arguments=tc.arguments)
238
+ try:
239
+ tool = self.registry.get(tc.name)
240
+ except KeyError:
241
+ result = f"ERROR: unknown tool {tc.name!r}"
242
+ ok = False
243
+ else:
244
+ action = Action(tool=tc.name, args=tc.arguments, risk=tool.risk)
245
+ decision = self.gate.check(action)
246
+ log.record("approval", tool=tc.name, decision=decision,
247
+ risk=tool.risk)
248
+ if decision != "allow":
249
+ result = (f"ERROR: permission gate denied {tc.name}: "
250
+ f"decision={decision}")
251
+ ok = False
252
+ else:
253
+ result, ok = self._run_tool_guarded(tool, tc.arguments,
254
+ cancel)
255
+ if not isinstance(result, str):
256
+ import json as _json
257
+ result = _json.dumps(result, default=str)
258
+ log.record_tool_result(tc.id, tc.name, result, ok=ok)
259
+ self.messages.append({"role": "tool", "tool_call_id": tc.id,
260
+ "name": tc.name, "content": result})
261
+
262
+ def _run_tool_guarded(self, tool, args: dict,
263
+ cancel: threading.Event) -> tuple[str, bool]:
264
+ """Run a tool in a worker thread with a timeout. Tools report
265
+ errors as strings; unexpected exceptions become ERROR strings."""
266
+ box: dict = {}
267
+
268
+ def _target():
269
+ try:
270
+ box["out"] = tool.run(**args)
271
+ except Exception as exc: # noqa: BLE001
272
+ box["out"] = f"ERROR: {type(exc).__name__}: {exc}"
273
+
274
+ t = threading.Thread(target=_target, daemon=True)
275
+ t.start()
276
+ # Cooperative cancellation: poll in small slices.
277
+ waited = 0.0
278
+ while t.is_alive() and waited < self.tool_timeout:
279
+ if cancel.is_set():
280
+ return "ERROR: cancelled by user", False
281
+ time.sleep(0.05)
282
+ waited += 0.05
283
+ if t.is_alive():
284
+ return (f"ERROR: tool {tool.name} timed out after "
285
+ f"{self.tool_timeout:.0f}s", False)
286
+ out = box.get("out", "ERROR: tool produced no output")
287
+ ok = not (isinstance(out, str) and out.startswith("ERROR:"))
288
+ return out, ok
289
+
290
+ # -- context budget -----------------------------------------------------
291
+
292
+ def _enforce_budget(self) -> None:
293
+ """Keep estimated tokens under budget.
294
+
295
+ 1. Truncate the oldest tool outputs first (keep the newest two
296
+ intact — the model usually needs those).
297
+ 2. If still over budget, compact older tool exchanges into a short
298
+ deterministic summary.
299
+ """
300
+ if estimate_tokens(self.messages, self.model_name) \
301
+ <= self.max_context_tokens:
302
+ return
303
+ tool_msgs = [m for m in self.messages if m.get("role") == "tool"]
304
+ # Phase 1: truncate oldest tool outputs, newest two untouched.
305
+ for m in tool_msgs[:-2]:
306
+ content = str(m.get("content", ""))
307
+ if len(content) > 800:
308
+ m["content"] = (content[:800]
309
+ + f"\n... [truncated "
310
+ f"{len(content) - 800} chars for context]")
311
+ if estimate_tokens(self.messages, self.model_name) \
312
+ <= self.max_context_tokens:
313
+ return
314
+ # Phase 2: compact all but the last 4 tool exchanges into a summary.
315
+ keep_ids = {id(m) for m in tool_msgs[-4:]}
316
+ drop = [m for m in tool_msgs if id(m) not in keep_ids]
317
+ if drop:
318
+ lines = []
319
+ for m in drop:
320
+ first = str(m.get("content", "")).split("\n")[0][:160]
321
+ lines.append(f"- {m.get('name')}: {first}")
322
+ summary = ("[earlier tool activity compacted for context: "
323
+ f"{len(drop)} calls]\n" + "\n".join(lines))
324
+ # Replace the dropped messages' content in place (keeps ids).
325
+ for m in drop:
326
+ m["content"] = "[compacted]"
327
+ # Insert the summary right before the first kept tool message.
328
+ kept = [m for m in tool_msgs if id(m) in keep_ids]
329
+ idx = self.messages.index(kept[0]) if kept else len(self.messages)
330
+ self.messages.insert(idx, {"role": "user",
331
+ "content": summary})