monkeybot 2.1.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.
- monkeybot/__init__.py +3 -0
- monkeybot/cli/__init__.py +3 -0
- monkeybot/cli/__main__.py +8 -0
- monkeybot/cli/audio_io.py +8 -0
- monkeybot/cli/gateway_manager.py +17 -0
- monkeybot/cli/main.py +22 -0
- monkeybot/cli/push_to_talk.py +12 -0
- monkeybot/cli/realtime_client.py +13 -0
- monkeybot/core/__init__.py +19 -0
- monkeybot/core/attachments/__init__.py +22 -0
- monkeybot/core/attachments/catalog.py +62 -0
- monkeybot/core/attachments/config.py +52 -0
- monkeybot/core/attachments/freeze.py +158 -0
- monkeybot/core/attachments/resolve.py +70 -0
- monkeybot/core/attachments/store.py +180 -0
- monkeybot/core/attachments/text.py +72 -0
- monkeybot/core/attachments/tools.py +54 -0
- monkeybot/core/bootstrap.py +242 -0
- monkeybot/core/config/__init__.py +71 -0
- monkeybot/core/config/realtime_config.py +150 -0
- monkeybot/core/config/runtime_env.py +262 -0
- monkeybot/core/config/settings.py +341 -0
- monkeybot/core/config/validation.py +249 -0
- monkeybot/core/config/yaml_loader.py +45 -0
- monkeybot/core/context/__init__.py +781 -0
- monkeybot/core/context/campaign_context.py +8 -0
- monkeybot/core/context/common.py +14 -0
- monkeybot/core/context/curator.py +255 -0
- monkeybot/core/context/epoch.py +226 -0
- monkeybot/core/context/memory_prompt.py +222 -0
- monkeybot/core/context/tool_output_policy.py +270 -0
- monkeybot/core/context/tool_result_ingress.py +290 -0
- monkeybot/core/context/tool_shapers.py +361 -0
- monkeybot/core/hooks/__init__.py +261 -0
- monkeybot/core/llm/__init__.py +4 -0
- monkeybot/core/llm/provider.py +296 -0
- monkeybot/core/llm/realtime_provider.py +203 -0
- monkeybot/core/llm/usage.py +57 -0
- monkeybot/core/logging_utils.py +24 -0
- monkeybot/core/mcp/__init__.py +1 -0
- monkeybot/core/mcp/mcp_client.py +1215 -0
- monkeybot/core/mcp/ports_mcp.py +109 -0
- monkeybot/core/memory/__init__.py +24 -0
- monkeybot/core/memory/hook.py +413 -0
- monkeybot/core/memory/index_format.py +104 -0
- monkeybot/core/memory/integrity.py +180 -0
- monkeybot/core/memory/organizer.py +270 -0
- monkeybot/core/memory/storage_ops.py +139 -0
- monkeybot/core/memory/subsystem.py +91 -0
- monkeybot/core/messages/__init__.py +16 -0
- monkeybot/core/messages/convert_provider.py +41 -0
- monkeybot/core/messages/tool_integrity.py +262 -0
- monkeybot/core/messages/transform_context.py +84 -0
- monkeybot/core/path_safety.py +11 -0
- monkeybot/core/persistence/__init__.py +17 -0
- monkeybot/core/persistence/backends.py +236 -0
- monkeybot/core/persistence/db.py +28 -0
- monkeybot/core/persistence/durable_runs.py +286 -0
- monkeybot/core/persistence/firestore.py +658 -0
- monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
- monkeybot/core/persistence/history.py +156 -0
- monkeybot/core/persistence/postgres.py +895 -0
- monkeybot/core/persistence/runs.py +76 -0
- monkeybot/core/persistence/scheduled_loops.py +435 -0
- monkeybot/core/persistence/session_turn_locks.py +94 -0
- monkeybot/core/persistence/sqlite.py +218 -0
- monkeybot/core/persistence/sqlite_backend.py +74 -0
- monkeybot/core/persistence/thread_summary.py +61 -0
- monkeybot/core/persistence/transcript.py +194 -0
- monkeybot/core/persistence/usage.py +149 -0
- monkeybot/core/prompts/__init__.py +1 -0
- monkeybot/core/prompts/harness_prompt.py +197 -0
- monkeybot/core/prompts/prompt.py +215 -0
- monkeybot/core/runtime/__init__.py +1 -0
- monkeybot/core/runtime/context_budget.py +267 -0
- monkeybot/core/runtime/events.py +819 -0
- monkeybot/core/runtime/input_admission.py +154 -0
- monkeybot/core/runtime/loop.py +2374 -0
- monkeybot/core/runtime/provider_stream_mapper.py +159 -0
- monkeybot/core/runtime/realtime_loop.py +654 -0
- monkeybot/core/runtime/utterance_buffer.py +179 -0
- monkeybot/core/subagents/__init__.py +1 -0
- monkeybot/core/subagents/subagent_proto.py +331 -0
- monkeybot/core/subagents/subagent_worker.py +441 -0
- monkeybot/core/subagents/worker_pool.py +403 -0
- monkeybot/core/testing/__init__.py +1 -0
- monkeybot/core/testing/mocks_provider.py +86 -0
- monkeybot/core/testing/mocks_realtime_provider.py +137 -0
- monkeybot/core/tools/__init__.py +1 -0
- monkeybot/core/tools/core_tool_executor.py +1548 -0
- monkeybot/core/tools/inspector.py +226 -0
- monkeybot/core/tools/loop_inspector.py +45 -0
- monkeybot/core/tools/patch.py +480 -0
- monkeybot/core/tools/permission.py +284 -0
- monkeybot/core/tools/sandbox_executor.py +255 -0
- monkeybot/core/tools/spill_inventory.py +35 -0
- monkeybot/core/tools/terminal.py +381 -0
- monkeybot/core/tools/text_normalize.py +25 -0
- monkeybot/core/tools/types.py +33 -0
- monkeybot/core/tools/workspace_service.py +710 -0
- monkeybot/core/tools/workspace_tools.py +116 -0
- monkeybot/core/types/__init__.py +1 -0
- monkeybot/core/types/content_blocks.py +644 -0
- monkeybot/core/types/interfaces.py +156 -0
- monkeybot/core/types/types_tools.py +29 -0
- monkeybot/core/workspace/__init__.py +8 -0
- monkeybot/core/workspace/factory.py +45 -0
- monkeybot/core/workspace/gcs.py +130 -0
- monkeybot/core/workspace/local.py +162 -0
- monkeybot/core/workspace/protocol.py +45 -0
- monkeybot/core/workspace/s3.py +151 -0
- monkeybot/core/workspace_layout.py +27 -0
- monkeybot/gateway/__init__.py +1 -0
- monkeybot/gateway/bootstrap.py +18 -0
- monkeybot/gateway/main.py +47 -0
- monkeybot/gateway/realtime/__init__.py +31 -0
- monkeybot/gateway/realtime/app.py +321 -0
- monkeybot/gateway/realtime/deps.py +52 -0
- monkeybot/gateway/realtime/errors.py +81 -0
- monkeybot/gateway/realtime/guardrails.py +88 -0
- monkeybot/gateway/realtime/manager.py +77 -0
- monkeybot/gateway/realtime/metrics.py +144 -0
- monkeybot/gateway/realtime/routes.py +864 -0
- monkeybot/gateway/realtime/session.py +232 -0
- monkeybot/gateway/realtime/wire.py +412 -0
- monkeybot/gateway/realtime_main.py +49 -0
- monkeybot/gateway/sse/__init__.py +1 -0
- monkeybot/gateway/sse/app.py +733 -0
- monkeybot/gateway/sse/loop_port.py +31 -0
- monkeybot/gateway/sse/models.py +177 -0
- monkeybot/gateway/sse/reply_body.py +91 -0
- monkeybot/gateway/sse/routes.py +1101 -0
- monkeybot/gateway/sse/scheduler_routes.py +200 -0
- monkeybot/gateway/sse/scheduler_wiring.py +96 -0
- monkeybot/gateway/sse/session_bus.py +226 -0
- monkeybot/gateway/sse/sse.py +46 -0
- monkeybot/gateway/sse/workspace_layout.py +7 -0
- monkeybot/observability/__init__.py +220 -0
- monkeybot/observability/_state.py +10 -0
- monkeybot/observability/instrumentation.py +153 -0
- monkeybot/observability/propagation.py +65 -0
- monkeybot/observability/spans.py +455 -0
- monkeybot/providers/__init__.py +19 -0
- monkeybot/providers/_openai_compat.py +450 -0
- monkeybot/providers/_utils.py +473 -0
- monkeybot/providers/bedrock.py +145 -0
- monkeybot/providers/claude.py +125 -0
- monkeybot/providers/gemini.py +677 -0
- monkeybot/providers/gemini_live.py +398 -0
- monkeybot/providers/huggingface.py +129 -0
- monkeybot/providers/nvidia.py +104 -0
- monkeybot/providers/ollama.py +152 -0
- monkeybot/providers/openai.py +127 -0
- monkeybot/providers/pricing.py +60 -0
- monkeybot/providers/sampling.py +44 -0
- monkeybot/providers/vertex_claude.py +148 -0
- monkeybot/scaffold/__init__.py +33 -0
- monkeybot/scheduler/__init__.py +13 -0
- monkeybot/scheduler/__main__.py +4 -0
- monkeybot/scheduler/engine.py +333 -0
- monkeybot/scheduler/http_invoker.py +61 -0
- monkeybot/scheduler/interval.py +77 -0
- monkeybot/scheduler/tick_result.py +34 -0
- monkeybot/scheduler/worker.py +87 -0
- monkeybot/subagents/__init__.py +1 -0
- monkeybot/subagents/worker/__init__.py +1 -0
- monkeybot/subagents/worker/__main__.py +22 -0
- monkeybot/web_search/__init__.py +82 -0
- monkeybot/web_search/backends/__init__.py +5 -0
- monkeybot/web_search/backends/duckduckgo.py +32 -0
- monkeybot/web_search/backends/firecrawl.py +43 -0
- monkeybot/web_search/backends/tavily.py +45 -0
- monkeybot/web_search/protocol.py +25 -0
- monkeybot/web_search/tool.py +56 -0
- monkeybot-2.1.1.dist-info/METADATA +318 -0
- monkeybot-2.1.1.dist-info/RECORD +178 -0
- monkeybot-2.1.1.dist-info/WHEEL +4 -0
- monkeybot-2.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Amazon S3 :class:`WorkspaceStorage` (``boto3``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from botocore.exceptions import ClientError
|
|
9
|
+
|
|
10
|
+
from monkeybot.core.workspace.protocol import WorkspaceStorage
|
|
11
|
+
|
|
12
|
+
_log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class S3WorkspaceStorage:
|
|
16
|
+
"""Object-prefix layout: ``s3://bucket/{prefix}/{path}``."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, bucket: str, prefix: str = "") -> None:
|
|
19
|
+
import boto3
|
|
20
|
+
|
|
21
|
+
self._s3 = boto3.client("s3")
|
|
22
|
+
self._bucket = bucket
|
|
23
|
+
self._prefix = prefix.strip().strip("/").replace("\\", "/")
|
|
24
|
+
if self._prefix:
|
|
25
|
+
self._prefix = self._prefix + "/"
|
|
26
|
+
|
|
27
|
+
def _key(self, path: str) -> str:
|
|
28
|
+
key = path.strip().replace("\\", "/").lstrip("/")
|
|
29
|
+
return self._prefix + key if self._prefix else key
|
|
30
|
+
|
|
31
|
+
async def read_text(self, path: str) -> str:
|
|
32
|
+
key = self._key(path)
|
|
33
|
+
|
|
34
|
+
def _read() -> str:
|
|
35
|
+
resp = self._s3.get_object(Bucket=self._bucket, Key=key)
|
|
36
|
+
body: bytes = resp["Body"].read()
|
|
37
|
+
return body.decode("utf-8")
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
return await asyncio.to_thread(_read)
|
|
41
|
+
except ClientError as exc:
|
|
42
|
+
code = exc.response.get("Error", {}).get("Code", "")
|
|
43
|
+
if code in ("404", "NoSuchKey", "NotFound"):
|
|
44
|
+
raise FileNotFoundError(key) from exc
|
|
45
|
+
raise
|
|
46
|
+
|
|
47
|
+
async def write_text(self, path: str, content: str) -> None:
|
|
48
|
+
key = self._key(path)
|
|
49
|
+
|
|
50
|
+
def _write() -> None:
|
|
51
|
+
self._s3.put_object(
|
|
52
|
+
Bucket=self._bucket,
|
|
53
|
+
Key=key,
|
|
54
|
+
Body=content.encode("utf-8"),
|
|
55
|
+
ContentType="text/plain; charset=utf-8",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
await asyncio.to_thread(_write)
|
|
59
|
+
|
|
60
|
+
async def append_text(self, path: str, content: str) -> None:
|
|
61
|
+
key = self._key(path)
|
|
62
|
+
|
|
63
|
+
def _append() -> None:
|
|
64
|
+
try:
|
|
65
|
+
resp = self._s3.get_object(Bucket=self._bucket, Key=key)
|
|
66
|
+
cur = resp["Body"].read().decode("utf-8")
|
|
67
|
+
except ClientError as exc:
|
|
68
|
+
code = exc.response.get("Error", {}).get("Code", "")
|
|
69
|
+
if code not in ("404", "NoSuchKey", "NotFound"):
|
|
70
|
+
raise
|
|
71
|
+
cur = ""
|
|
72
|
+
self._s3.put_object(
|
|
73
|
+
Bucket=self._bucket,
|
|
74
|
+
Key=key,
|
|
75
|
+
Body=(cur + content).encode("utf-8"),
|
|
76
|
+
ContentType="text/plain; charset=utf-8",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
await asyncio.to_thread(_append)
|
|
80
|
+
|
|
81
|
+
async def exists(self, path: str) -> bool:
|
|
82
|
+
key = self._key(path)
|
|
83
|
+
|
|
84
|
+
def _head() -> bool:
|
|
85
|
+
try:
|
|
86
|
+
self._s3.head_object(Bucket=self._bucket, Key=key)
|
|
87
|
+
return True
|
|
88
|
+
except ClientError as exc:
|
|
89
|
+
code = exc.response.get("Error", {}).get("Code", "")
|
|
90
|
+
if code in ("404", "NoSuchKey", "NotFound"):
|
|
91
|
+
return False
|
|
92
|
+
raise
|
|
93
|
+
|
|
94
|
+
return await asyncio.to_thread(_head)
|
|
95
|
+
|
|
96
|
+
async def list_files(self, prefix: str = "") -> list[str]:
|
|
97
|
+
pfx = self._key(prefix)
|
|
98
|
+
|
|
99
|
+
def _list() -> list[str]:
|
|
100
|
+
out: list[str] = []
|
|
101
|
+
paginator = self._s3.get_paginator("list_objects_v2")
|
|
102
|
+
for page in paginator.paginate(Bucket=self._bucket, Prefix=pfx):
|
|
103
|
+
for obj in page.get("Contents", []):
|
|
104
|
+
name = obj["Key"]
|
|
105
|
+
if name.endswith("/"):
|
|
106
|
+
continue
|
|
107
|
+
rel = name[len(self._prefix) :] if self._prefix else name
|
|
108
|
+
out.append(rel.lstrip("/"))
|
|
109
|
+
return sorted(out)
|
|
110
|
+
|
|
111
|
+
return await asyncio.to_thread(_list)
|
|
112
|
+
|
|
113
|
+
async def delete(self, path: str) -> None:
|
|
114
|
+
key = self._key(path)
|
|
115
|
+
|
|
116
|
+
def _del() -> None:
|
|
117
|
+
self._s3.delete_object(Bucket=self._bucket, Key=key)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
await asyncio.to_thread(_del)
|
|
121
|
+
except ClientError as exc:
|
|
122
|
+
code = exc.response.get("Error", {}).get("Code", "")
|
|
123
|
+
if code not in ("404", "NoSuchKey", "NotFound"):
|
|
124
|
+
raise
|
|
125
|
+
|
|
126
|
+
async def move(self, src: str, dest: str) -> None:
|
|
127
|
+
sk = self._key(src)
|
|
128
|
+
dk = self._key(dest)
|
|
129
|
+
|
|
130
|
+
def _copy_delete() -> None:
|
|
131
|
+
self._s3.copy_object(
|
|
132
|
+
Bucket=self._bucket,
|
|
133
|
+
CopySource={"Bucket": self._bucket, "Key": sk},
|
|
134
|
+
Key=dk,
|
|
135
|
+
)
|
|
136
|
+
try:
|
|
137
|
+
self._s3.delete_object(Bucket=self._bucket, Key=sk)
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
_log.warning("s3 move: delete source failed after copy src=%s dest=%s: %r", sk, dk, exc)
|
|
140
|
+
|
|
141
|
+
await asyncio.to_thread(_copy_delete)
|
|
142
|
+
|
|
143
|
+
async def gc_prefix(self, prefix: str, max_age_sec: float) -> dict[str, int]:
|
|
144
|
+
del prefix, max_age_sec
|
|
145
|
+
_log.info(
|
|
146
|
+
"s3 gc_prefix: skipped — configure S3 lifecycle rules on the bucket for prefix cleanup"
|
|
147
|
+
)
|
|
148
|
+
return {"scanned": 0, "deleted": 0, "errors": 0}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
__all__ = ["S3WorkspaceStorage"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Agent-facing workspace root resolution (shared by gateway and library entry points)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def resolve_agent_workspace_root() -> Path:
|
|
10
|
+
"""Directory the agent should treat as its workspace (``data/``, ``skills/``, spill, …).
|
|
11
|
+
|
|
12
|
+
Resolution order:
|
|
13
|
+
|
|
14
|
+
1. ``MONKEYBOT_WORKSPACE_ROOT`` — absolute path, or relative to process cwd.
|
|
15
|
+
2. ``WORKSPACE_ROOT`` — same semantics (alias for serverless / legacy handlers).
|
|
16
|
+
3. Else if ``<cwd>/workspace`` exists and is a directory, use it.
|
|
17
|
+
4. Else process cwd.
|
|
18
|
+
"""
|
|
19
|
+
for env_name in ("MONKEYBOT_WORKSPACE_ROOT", "WORKSPACE_ROOT"):
|
|
20
|
+
raw = os.environ.get(env_name, "").strip()
|
|
21
|
+
if raw:
|
|
22
|
+
p = Path(raw)
|
|
23
|
+
return p.resolve() if p.is_absolute() else (Path.cwd() / p).resolve()
|
|
24
|
+
nested = (Path.cwd() / "workspace").resolve()
|
|
25
|
+
if nested.is_dir():
|
|
26
|
+
return nested
|
|
27
|
+
return Path.cwd().resolve()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Gateway — FastAPI SSE interface (v2)."""
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Process-level bootstrap before the FastAPI app is constructed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def ensure_gateway_runtime_env() -> None:
|
|
12
|
+
"""Load ``.env`` then optional ``monkeybot_config/monkeybot.yaml`` (see ``monkeybot.core.config.runtime_env``)."""
|
|
13
|
+
load_dotenv()
|
|
14
|
+
if not os.environ.get("MONKEYBOT_AGENT_ROOT", "").strip():
|
|
15
|
+
os.environ["MONKEYBOT_AGENT_ROOT"] = str(Path.cwd().resolve())
|
|
16
|
+
from monkeybot.core.config.runtime_env import apply_monkeybot_runtime_env
|
|
17
|
+
|
|
18
|
+
apply_monkeybot_runtime_env()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Gateway server entrypoint (SSE harness).
|
|
3
|
+
|
|
4
|
+
Run locally with:
|
|
5
|
+
|
|
6
|
+
python -m monkeybot.gateway.main
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
from monkeybot.core.logging_utils import normalize_log_level
|
|
15
|
+
from monkeybot.gateway.bootstrap import ensure_gateway_runtime_env
|
|
16
|
+
|
|
17
|
+
ensure_gateway_runtime_env()
|
|
18
|
+
|
|
19
|
+
logging.basicConfig(
|
|
20
|
+
level=normalize_log_level(os.getenv("LOG_LEVEL")),
|
|
21
|
+
format="%(levelname)s:%(name)s:%(message)s",
|
|
22
|
+
)
|
|
23
|
+
# Suppress chatty third-party loggers regardless of LOG_LEVEL.
|
|
24
|
+
for _noisy in ("httpx", "httpcore", "urllib3", "google_genai", "google.auth"):
|
|
25
|
+
logging.getLogger(_noisy).setLevel(logging.WARNING)
|
|
26
|
+
|
|
27
|
+
from monkeybot.gateway.sse.app import app # noqa: E402
|
|
28
|
+
|
|
29
|
+
__all__ = ["app"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
import uvicorn
|
|
34
|
+
|
|
35
|
+
port = int(os.getenv("PORT", os.getenv("GATEWAY_PORT", "8000")))
|
|
36
|
+
log_level = os.getenv("LOG_LEVEL", "info").lower()
|
|
37
|
+
|
|
38
|
+
timeout_graceful_shutdown = int(os.getenv("GRACEFUL_SHUTDOWN_TIMEOUT_SEC", "5"))
|
|
39
|
+
|
|
40
|
+
uvicorn.run(
|
|
41
|
+
app,
|
|
42
|
+
host="0.0.0.0",
|
|
43
|
+
port=port,
|
|
44
|
+
log_level=log_level,
|
|
45
|
+
access_log=True,
|
|
46
|
+
timeout_graceful_shutdown=timeout_graceful_shutdown,
|
|
47
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Realtime WebSocket gateway for MonkeyBot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from monkeybot.gateway.realtime.app import create_realtime_app
|
|
6
|
+
from monkeybot.gateway.realtime.deps import RealtimeDependencies
|
|
7
|
+
from monkeybot.gateway.realtime.errors import RealtimeError
|
|
8
|
+
from monkeybot.gateway.realtime.guardrails import run_guardrails
|
|
9
|
+
from monkeybot.gateway.realtime.manager import RealtimeSessionManager
|
|
10
|
+
from monkeybot.gateway.realtime.metrics import RealtimeMetrics
|
|
11
|
+
from monkeybot.gateway.realtime.routes import create_realtime_router
|
|
12
|
+
from monkeybot.gateway.realtime.session import RealtimeConnectionState
|
|
13
|
+
from monkeybot.gateway.realtime.wire import (
|
|
14
|
+
ServerConnectedFrame,
|
|
15
|
+
encode_server_frame,
|
|
16
|
+
parse_client_frame,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"RealtimeConnectionState",
|
|
21
|
+
"RealtimeDependencies",
|
|
22
|
+
"RealtimeError",
|
|
23
|
+
"RealtimeMetrics",
|
|
24
|
+
"RealtimeSessionManager",
|
|
25
|
+
"ServerConnectedFrame",
|
|
26
|
+
"create_realtime_app",
|
|
27
|
+
"create_realtime_router",
|
|
28
|
+
"encode_server_frame",
|
|
29
|
+
"parse_client_frame",
|
|
30
|
+
"run_guardrails",
|
|
31
|
+
]
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""FastAPI application factory for the realtime gateway.
|
|
2
|
+
|
|
3
|
+
This creates a new FastAPI app that mounts the SSE gateway routes **plus** the realtime
|
|
4
|
+
WebSocket endpoint. The existing ``gateway/sse/app.py`` is left untouched.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from collections.abc import AsyncIterator
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from fastapi import FastAPI
|
|
17
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
18
|
+
|
|
19
|
+
from monkeybot.core.attachments.config import attachments_enabled_from_env
|
|
20
|
+
from monkeybot.core.attachments.store import FilesystemAttachmentStore
|
|
21
|
+
from monkeybot.core.config.realtime_config import RealtimeConfig, get_realtime_config
|
|
22
|
+
from monkeybot.core.config.settings import (
|
|
23
|
+
auto_schema_enabled_from_config,
|
|
24
|
+
get_subagent_registry,
|
|
25
|
+
normalize_model_provider,
|
|
26
|
+
vertex_google_search_enabled_from_config,
|
|
27
|
+
)
|
|
28
|
+
from monkeybot.core.hooks import HookManager
|
|
29
|
+
from monkeybot.core.llm.provider import Provider
|
|
30
|
+
from monkeybot.core.mcp.mcp_client import MCPClient
|
|
31
|
+
from monkeybot.core.memory.subsystem import MemorySubsystem
|
|
32
|
+
from monkeybot.core.persistence.backends import create_storage_backend
|
|
33
|
+
from monkeybot.core.tools.inspector import CommandTierInspector, RulesInspector
|
|
34
|
+
from monkeybot.core.tools.permission import try_load_permission_inspector
|
|
35
|
+
from monkeybot.core.workspace import create_workspace_storage
|
|
36
|
+
from monkeybot.gateway.bootstrap import ensure_gateway_runtime_env
|
|
37
|
+
from monkeybot.gateway.sse.routes import create_app as build_sse_app
|
|
38
|
+
from monkeybot.gateway.sse.workspace_layout import resolve_agent_workspace_root
|
|
39
|
+
from monkeybot.providers.gemini_live import GeminiLiveProvider
|
|
40
|
+
from monkeybot.web_search import WebSearchTool, build_backend
|
|
41
|
+
|
|
42
|
+
from .deps import RealtimeDependencies
|
|
43
|
+
from .manager import RealtimeSessionManager
|
|
44
|
+
from .routes import create_realtime_router
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _memory_enabled() -> bool:
|
|
50
|
+
raw = os.environ.get("MONKEYBOT_MEMORY_HOOK_ENABLED", "true").strip().lower()
|
|
51
|
+
return raw not in {"0", "false", "no", "off"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _memory_storage_uri() -> str:
|
|
55
|
+
uri = os.environ.get("MEMORY_STORAGE_URI", "").strip()
|
|
56
|
+
if uri:
|
|
57
|
+
return uri
|
|
58
|
+
raw_mp = os.environ.get("MEMORY_PATH")
|
|
59
|
+
if raw_mp is not None:
|
|
60
|
+
logger.info(
|
|
61
|
+
"memory: deriving storage URI from MEMORY_PATH; prefer paths.memory_storage_uri in monkeybot.yaml"
|
|
62
|
+
)
|
|
63
|
+
legacy = (raw_mp or "data/memory").strip()
|
|
64
|
+
mem = Path(legacy)
|
|
65
|
+
resolved = mem.resolve() if mem.is_absolute() else (Path.cwd().resolve() / mem).resolve()
|
|
66
|
+
return f"local://{resolved}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _tool_denied_patterns() -> list[str]:
|
|
70
|
+
raw = os.environ.get("MONKEYBOT_TOOL_DENIED_PATTERNS")
|
|
71
|
+
if raw is None:
|
|
72
|
+
return ["rm -rf", "/etc/passwd", "DROP TABLE"]
|
|
73
|
+
return [p.strip() for p in raw.split(",") if p.strip()]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _resolve_provider() -> Provider:
|
|
77
|
+
from monkeybot.core.config.settings import get_provider_config
|
|
78
|
+
from monkeybot.core.testing.mocks_provider import ScriptedFakeProvider
|
|
79
|
+
|
|
80
|
+
mode = normalize_model_provider(os.environ.get("MODEL_PROVIDER", "google_vertexai"))
|
|
81
|
+
if mode == "fake":
|
|
82
|
+
return ScriptedFakeProvider([])
|
|
83
|
+
return get_provider_config(provider=mode).provider
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@contextlib.asynccontextmanager
|
|
87
|
+
async def _realtime_lifespan(
|
|
88
|
+
app: FastAPI,
|
|
89
|
+
deps: RealtimeDependencies,
|
|
90
|
+
config: RealtimeConfig,
|
|
91
|
+
) -> AsyncIterator[None]:
|
|
92
|
+
"""Wire storage, MCP, inspectors, memory, and realtime provider."""
|
|
93
|
+
db_url = os.environ.get("DB_URL", "sqlite:///data/monkeybot.db")
|
|
94
|
+
backend = create_storage_backend(db_url)
|
|
95
|
+
await backend.open(run_schema=auto_schema_enabled_from_config())
|
|
96
|
+
app.state.storage = backend
|
|
97
|
+
deps.storage = backend
|
|
98
|
+
|
|
99
|
+
mcp = MCPClient()
|
|
100
|
+
deps.mcp = mcp
|
|
101
|
+
mcp_config = Path(os.environ.get("MCP_CONFIG", "/app/monkeybot_config/mcp.json"))
|
|
102
|
+
strict = os.environ.get("MCP_STRICT_LOAD", "").strip().lower() in ("1", "true", "yes")
|
|
103
|
+
try:
|
|
104
|
+
await mcp.load_from_config(mcp_config, raise_on_error=strict)
|
|
105
|
+
except OSError as exc:
|
|
106
|
+
logger.info("MCP config skipped (%s): %s", mcp_config, exc)
|
|
107
|
+
|
|
108
|
+
tiers_path = Path(
|
|
109
|
+
os.environ.get("COMMAND_ALLOWLIST_CONFIG", "/app/monkeybot_config/command_allowlist.yaml")
|
|
110
|
+
)
|
|
111
|
+
deps.run_command_allowed_commands = None
|
|
112
|
+
deps.run_command_allowed_path_prefixes = None
|
|
113
|
+
inspectors: list[Any] = []
|
|
114
|
+
try:
|
|
115
|
+
tier_insp = CommandTierInspector(tiers_path)
|
|
116
|
+
inspectors.append(tier_insp)
|
|
117
|
+
deps.run_command_allowed_commands = list(tier_insp.allowed_commands)
|
|
118
|
+
deps.run_command_allowed_path_prefixes = list(tier_insp.allowed_path_prefixes)
|
|
119
|
+
except FileNotFoundError:
|
|
120
|
+
logger.info("command tiers missing (%s); allowing all tool calls", tiers_path)
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
logger.exception("command tier load failed: %s", exc)
|
|
123
|
+
|
|
124
|
+
denied = _tool_denied_patterns()
|
|
125
|
+
if denied:
|
|
126
|
+
inspectors.append(RulesInspector(denied))
|
|
127
|
+
|
|
128
|
+
perm_path = Path(
|
|
129
|
+
os.environ.get("PERMISSION_CONFIG", "/app/monkeybot_config/permissions.yaml")
|
|
130
|
+
)
|
|
131
|
+
perm_insp = try_load_permission_inspector(perm_path)
|
|
132
|
+
if perm_insp is not None:
|
|
133
|
+
inspectors.append(perm_insp)
|
|
134
|
+
|
|
135
|
+
deps.inspectors = inspectors
|
|
136
|
+
|
|
137
|
+
# Realtime model can be a Live-only preview while the main turn-based model
|
|
138
|
+
# stays a regular generateContent model. Use realtime.model.provider if set,
|
|
139
|
+
# otherwise fall back to the main provider.
|
|
140
|
+
realtime_provider = normalize_model_provider(
|
|
141
|
+
config.model.provider or os.environ.get("MODEL_PROVIDER", "google_vertexai")
|
|
142
|
+
)
|
|
143
|
+
if realtime_provider == "google_genai":
|
|
144
|
+
api_key = os.environ.get("GEMINI_API_KEY", "")
|
|
145
|
+
if not api_key:
|
|
146
|
+
logger.warning("realtime MODEL_PROVIDER=google_genai but GEMINI_API_KEY is not set")
|
|
147
|
+
deps.realtime_provider = GeminiLiveProvider(api_key=api_key)
|
|
148
|
+
else:
|
|
149
|
+
deps.realtime_provider = GeminiLiveProvider()
|
|
150
|
+
|
|
151
|
+
provider = _resolve_provider()
|
|
152
|
+
vertex_gs = vertex_google_search_enabled_from_config()
|
|
153
|
+
try:
|
|
154
|
+
backend_ws = build_backend()
|
|
155
|
+
if backend_ws is not None:
|
|
156
|
+
deps.web_search_tool = WebSearchTool(backend_ws)
|
|
157
|
+
logger.info("web search enabled: backend=%s", backend_ws.name)
|
|
158
|
+
else:
|
|
159
|
+
deps.web_search_tool = None
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
logger.warning("web search backend init failed — disabling: %s", exc)
|
|
162
|
+
deps.web_search_tool = None
|
|
163
|
+
|
|
164
|
+
if vertex_gs:
|
|
165
|
+
logger.info("vertex google_search grounding enabled")
|
|
166
|
+
if deps.web_search_tool is None and not vertex_gs:
|
|
167
|
+
logger.info("web search disabled (WEB_SEARCH_BACKEND=none)")
|
|
168
|
+
|
|
169
|
+
if _memory_enabled():
|
|
170
|
+
try:
|
|
171
|
+
mem_uri = _memory_storage_uri()
|
|
172
|
+
storage = create_workspace_storage(mem_uri)
|
|
173
|
+
model_name = os.environ.get("MODEL_NAME", "gemini-2.5-flash")
|
|
174
|
+
memory = MemorySubsystem(
|
|
175
|
+
storage=storage,
|
|
176
|
+
provider=provider,
|
|
177
|
+
model=model_name,
|
|
178
|
+
memory_uri=mem_uri,
|
|
179
|
+
)
|
|
180
|
+
mgr = HookManager()
|
|
181
|
+
memory.register_hooks(mgr)
|
|
182
|
+
deps.hook_manager = mgr
|
|
183
|
+
deps.memory = memory
|
|
184
|
+
app.state.memory = memory
|
|
185
|
+
logger.info("memory hook enabled (memory_storage_uri=%s)", mem_uri)
|
|
186
|
+
except Exception as exc:
|
|
187
|
+
logger.warning("memory hook init failed — disabling: %s", exc)
|
|
188
|
+
else:
|
|
189
|
+
logger.info("memory hook disabled")
|
|
190
|
+
|
|
191
|
+
deps.subagent_registry = get_subagent_registry()
|
|
192
|
+
|
|
193
|
+
if attachments_enabled_from_env():
|
|
194
|
+
try:
|
|
195
|
+
deps.attachment_store = FilesystemAttachmentStore(resolve_agent_workspace_root())
|
|
196
|
+
app.state.attachment_store = deps.attachment_store
|
|
197
|
+
logger.info("attachments enabled")
|
|
198
|
+
except Exception as exc:
|
|
199
|
+
logger.warning("attachment store init failed: %s", exc)
|
|
200
|
+
deps.attachment_store = None
|
|
201
|
+
app.state.attachment_store = None
|
|
202
|
+
else:
|
|
203
|
+
deps.attachment_store = None
|
|
204
|
+
app.state.attachment_store = None
|
|
205
|
+
logger.info("attachments disabled via ATTACHMENTS_ENABLED")
|
|
206
|
+
|
|
207
|
+
deps.freeze()
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
yield
|
|
211
|
+
finally:
|
|
212
|
+
if deps.mcp is not None:
|
|
213
|
+
for name in list(getattr(deps.mcp, "_servers", {}).keys()):
|
|
214
|
+
try:
|
|
215
|
+
await deps.mcp.disconnect(name)
|
|
216
|
+
except Exception:
|
|
217
|
+
logger.exception("MCP disconnect failed for %s", name)
|
|
218
|
+
if deps.storage is not None:
|
|
219
|
+
await deps.storage.close()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@contextlib.asynccontextmanager
|
|
223
|
+
async def _combined_lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
224
|
+
"""SSE startup (turn-based /reply) plus realtime Live provider wiring.
|
|
225
|
+
|
|
226
|
+
Chat and talk share this process. SSE ``GatewayLoopPort`` reads module-level
|
|
227
|
+
``_deps`` from ``gateway.sse.app``, so we must run SSE ``_startup`` here —
|
|
228
|
+
not only the realtime-only lifespan (which left /reply with a no-op loop).
|
|
229
|
+
"""
|
|
230
|
+
from monkeybot.gateway.sse.app import _deps as sse_deps
|
|
231
|
+
from monkeybot.gateway.sse.app import _shutdown as sse_shutdown
|
|
232
|
+
from monkeybot.gateway.sse.app import _startup as sse_startup
|
|
233
|
+
|
|
234
|
+
deps: RealtimeDependencies = app.state.realtime_deps
|
|
235
|
+
config: RealtimeConfig = app.state.realtime_config
|
|
236
|
+
|
|
237
|
+
await sse_startup(app)
|
|
238
|
+
|
|
239
|
+
# Bridge shared process deps into RealtimeDependencies (no second DB open).
|
|
240
|
+
deps.storage = app.state.storage
|
|
241
|
+
deps.mcp = sse_deps.mcp
|
|
242
|
+
deps.inspectors = list(sse_deps.inspectors)
|
|
243
|
+
deps.memory = sse_deps.memory
|
|
244
|
+
deps.hook_manager = sse_deps.hook_manager
|
|
245
|
+
deps.web_search_tool = sse_deps.web_search_tool
|
|
246
|
+
deps.run_command_allowed_commands = sse_deps.run_command_allowed_commands
|
|
247
|
+
deps.run_command_allowed_path_prefixes = sse_deps.run_command_allowed_path_prefixes
|
|
248
|
+
deps.subagent_registry = sse_deps.subagent_registry
|
|
249
|
+
deps.attachment_store = getattr(app.state, "attachment_store", None)
|
|
250
|
+
|
|
251
|
+
realtime_provider = normalize_model_provider(
|
|
252
|
+
config.model.provider or os.environ.get("MODEL_PROVIDER", "google_vertexai")
|
|
253
|
+
)
|
|
254
|
+
if realtime_provider == "google_genai":
|
|
255
|
+
api_key = os.environ.get("GEMINI_API_KEY", "")
|
|
256
|
+
if not api_key:
|
|
257
|
+
logger.warning("realtime MODEL_PROVIDER=google_genai but GEMINI_API_KEY is not set")
|
|
258
|
+
deps.realtime_provider = GeminiLiveProvider(api_key=api_key)
|
|
259
|
+
else:
|
|
260
|
+
deps.realtime_provider = GeminiLiveProvider()
|
|
261
|
+
|
|
262
|
+
deps.freeze()
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
yield
|
|
266
|
+
finally:
|
|
267
|
+
await sse_shutdown(app)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def create_realtime_app(**kwargs: Any) -> FastAPI:
|
|
271
|
+
"""Build a FastAPI app serving both SSE routes and the realtime WebSocket endpoint.
|
|
272
|
+
|
|
273
|
+
Extra keyword arguments are forwarded to the underlying SSE app factory.
|
|
274
|
+
"""
|
|
275
|
+
ensure_gateway_runtime_env()
|
|
276
|
+
|
|
277
|
+
config = get_realtime_config()
|
|
278
|
+
manager = RealtimeSessionManager(config)
|
|
279
|
+
deps = RealtimeDependencies()
|
|
280
|
+
|
|
281
|
+
# Wire the real turn-based loop (same as gateway.sse.app). Without this,
|
|
282
|
+
# build_sse_app installs a no-op default that clears busy and emits nothing —
|
|
283
|
+
# which leaves the chat TUI stuck on "thinking…".
|
|
284
|
+
from monkeybot.gateway.sse.app import GatewayLoopPort, _StaticUsagePortZeros
|
|
285
|
+
from monkeybot.gateway.sse.session_bus import SessionRegistry
|
|
286
|
+
|
|
287
|
+
registry = kwargs.pop("registry", None) or SessionRegistry()
|
|
288
|
+
loop_port = kwargs.pop("loop_port", None) or GatewayLoopPort(registry)
|
|
289
|
+
usage_port = kwargs.pop("usage_port", None) or _StaticUsagePortZeros()
|
|
290
|
+
|
|
291
|
+
app = build_sse_app(
|
|
292
|
+
lifespan=_combined_lifespan,
|
|
293
|
+
registry=registry,
|
|
294
|
+
loop_port=loop_port,
|
|
295
|
+
usage_port=usage_port,
|
|
296
|
+
**kwargs,
|
|
297
|
+
)
|
|
298
|
+
if isinstance(loop_port, GatewayLoopPort):
|
|
299
|
+
loop_port.bind_app(app)
|
|
300
|
+
|
|
301
|
+
app.state.realtime_config = config
|
|
302
|
+
app.state.realtime_deps = deps
|
|
303
|
+
app.state.realtime_manager = manager
|
|
304
|
+
|
|
305
|
+
if not any(isinstance(m, CORSMiddleware) for m in app.user_middleware):
|
|
306
|
+
app.add_middleware(
|
|
307
|
+
CORSMiddleware,
|
|
308
|
+
allow_origins=["*"],
|
|
309
|
+
allow_credentials=True,
|
|
310
|
+
allow_methods=["*"],
|
|
311
|
+
allow_headers=["*"],
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
router = create_realtime_router(deps, manager)
|
|
315
|
+
app.include_router(router)
|
|
316
|
+
|
|
317
|
+
return app
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# Module-level app for ``uvicorn monkeybot.gateway.realtime.app:app``.
|
|
321
|
+
app = create_realtime_app()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Process-level dependencies for the realtime gateway.
|
|
2
|
+
|
|
3
|
+
This mirrors the SSE gateway's ``_GatewayDeps`` but is owned by the realtime package so
|
|
4
|
+
that the realtime app can be wired independently without modifying ``gateway/sse/app.py``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from monkeybot.core.attachments.store import AttachmentStore
|
|
13
|
+
from monkeybot.core.config.settings import SubagentConfig
|
|
14
|
+
from monkeybot.core.hooks import HookManager
|
|
15
|
+
from monkeybot.core.llm.realtime_provider import RealtimeProvider
|
|
16
|
+
from monkeybot.core.mcp.ports_mcp import MCPClientPort
|
|
17
|
+
from monkeybot.core.memory.subsystem import MemorySubsystem
|
|
18
|
+
from monkeybot.core.persistence.backends import StorageBackend
|
|
19
|
+
from monkeybot.core.tools.inspector import ToolInspector
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class RealtimeDependencies:
|
|
24
|
+
"""Mutable process singleton populated on realtime app startup.
|
|
25
|
+
|
|
26
|
+
Call :meth:`freeze` after lifespan setup so accidental post-startup mutation fails
|
|
27
|
+
loudly (single-process assumption; multi-replica still needs sticky WS routing).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
mcp: MCPClientPort | None = None
|
|
31
|
+
inspectors: list[ToolInspector] = field(default_factory=list)
|
|
32
|
+
memory: MemorySubsystem | None = None
|
|
33
|
+
hook_manager: HookManager | None = None
|
|
34
|
+
web_search_tool: Any | None = None
|
|
35
|
+
run_command_allowed_commands: list[str] | None = None
|
|
36
|
+
run_command_allowed_path_prefixes: list[str] | None = None
|
|
37
|
+
subagent_registry: dict[str, SubagentConfig] = field(default_factory=dict)
|
|
38
|
+
realtime_provider: RealtimeProvider | None = None
|
|
39
|
+
storage: StorageBackend | None = None
|
|
40
|
+
attachment_store: AttachmentStore | None = None
|
|
41
|
+
_frozen: bool = field(default=False, repr=False)
|
|
42
|
+
|
|
43
|
+
def freeze(self) -> None:
|
|
44
|
+
"""Lock dependency fields after startup wiring completes."""
|
|
45
|
+
object.__setattr__(self, "_frozen", True)
|
|
46
|
+
|
|
47
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
48
|
+
if name != "_frozen" and getattr(self, "_frozen", False):
|
|
49
|
+
raise RuntimeError(
|
|
50
|
+
f"RealtimeDependencies is frozen; cannot set {name!r} after startup"
|
|
51
|
+
)
|
|
52
|
+
object.__setattr__(self, name, value)
|