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
monkeybot/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Shim — implementation lives in ``monkeybot_cli.realtime.audio_io``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from monkeybot_cli.realtime.audio_io import * # noqa: F403
|
|
6
|
+
from monkeybot_cli.realtime.audio_io import AudioIOError, AudioPlayer, AudioRecorder
|
|
7
|
+
|
|
8
|
+
__all__ = ["AudioIOError", "AudioPlayer", "AudioRecorder"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Shim — implementation lives in ``monkeybot_cli.realtime.gateway_manager``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from monkeybot_cli.realtime.gateway_manager import ( # noqa: F401
|
|
6
|
+
_find_workspace_dir,
|
|
7
|
+
_url_is_local,
|
|
8
|
+
start_gateway_if_needed,
|
|
9
|
+
stop_gateway,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"_find_workspace_dir",
|
|
14
|
+
"_url_is_local",
|
|
15
|
+
"start_gateway_if_needed",
|
|
16
|
+
"stop_gateway",
|
|
17
|
+
]
|
monkeybot/cli/main.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Shim — realtime talk helpers live in ``monkeybot_cli.realtime.session``.
|
|
2
|
+
|
|
3
|
+
The user-facing ``monkeybot`` console script lives in the ``monkeybot-cli`` package
|
|
4
|
+
(``cli/``). This module re-exports so ``python -m monkeybot.cli`` and legacy imports
|
|
5
|
+
keep working when ``monkeybot-cli`` is installed (root ``dev`` group / ``cli/`` sync).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from monkeybot_cli.realtime.session import ( # noqa: F401
|
|
11
|
+
app,
|
|
12
|
+
main,
|
|
13
|
+
run_talk_session,
|
|
14
|
+
talk,
|
|
15
|
+
version,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = ["app", "main", "run_talk_session", "talk", "version"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
if __name__ == "__main__":
|
|
22
|
+
main()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Shim — implementation lives in ``monkeybot_cli.realtime.push_to_talk``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from monkeybot_cli.realtime import push_to_talk as _impl
|
|
6
|
+
from monkeybot_cli.realtime.push_to_talk import PushToTalkError, PushToTalkGate
|
|
7
|
+
|
|
8
|
+
# Re-export module attrs so tests can patch ``monkeybot.cli.push_to_talk._HAS_PYNPUT``.
|
|
9
|
+
_HAS_PYNPUT = _impl._HAS_PYNPUT
|
|
10
|
+
keyboard = _impl.keyboard
|
|
11
|
+
|
|
12
|
+
__all__ = ["PushToTalkError", "PushToTalkGate", "_HAS_PYNPUT", "keyboard"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Shim — encode helpers live in ``monkeybot_cli.realtime``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from monkeybot_cli.exit_commands import is_exit_command
|
|
6
|
+
from monkeybot_cli.realtime.client import RealtimeClientError
|
|
7
|
+
from monkeybot_cli.realtime.wire_encode import encode_client_frame
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"RealtimeClientError",
|
|
11
|
+
"encode_client_frame",
|
|
12
|
+
"is_exit_command",
|
|
13
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Core agent components for monkeybot v2.
|
|
2
|
+
|
|
3
|
+
Subpackages (import concrete modules from these paths, e.g.
|
|
4
|
+
``monkeybot.core.runtime.loop``):
|
|
5
|
+
|
|
6
|
+
- ``types`` — protocols, errors, content blocks, tool definitions
|
|
7
|
+
- ``config`` — bot config, secrets, runtime env mapping
|
|
8
|
+
- ``llm`` — streaming ``Provider`` protocol and per-turn usage; vendor backends in ``monkeybot.providers``
|
|
9
|
+
- ``runtime`` — agent loop and typed streaming events
|
|
10
|
+
- ``context`` — per-turn context assembly and curator
|
|
11
|
+
- ``memory`` — filesystem memory, hook, organizer
|
|
12
|
+
- ``persistence`` — SQLite schema, conversation history, run ids, durable runs
|
|
13
|
+
- ``tools`` — tool executor, workspace I/O, sandbox, terminal, inspector
|
|
14
|
+
- ``mcp`` — MCP client and port types
|
|
15
|
+
- ``prompts`` — system prompt and harness helpers
|
|
16
|
+
- ``hooks`` — lifecycle hook manager
|
|
17
|
+
- ``subagents`` — subprocess worker and spawn protocol
|
|
18
|
+
- ``testing`` — in-repo test doubles (mocks)
|
|
19
|
+
"""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Session-scoped attachment upload, resolve, freeze, and catalog."""
|
|
2
|
+
|
|
3
|
+
from .catalog import AttachmentRecord, SessionAttachmentCatalog
|
|
4
|
+
from .config import attachments_enabled_from_env
|
|
5
|
+
from .freeze import freeze_attachments_in_history
|
|
6
|
+
from .resolve import AttachmentResolveError, resolve_messages_for_provider
|
|
7
|
+
from .store import AttachmentStore, FilesystemAttachmentStore, sniff_mime
|
|
8
|
+
from .text import parse_attachment_descriptor_text, render_attachment_descriptor_text
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AttachmentRecord",
|
|
12
|
+
"AttachmentResolveError",
|
|
13
|
+
"AttachmentStore",
|
|
14
|
+
"FilesystemAttachmentStore",
|
|
15
|
+
"SessionAttachmentCatalog",
|
|
16
|
+
"attachments_enabled_from_env",
|
|
17
|
+
"freeze_attachments_in_history",
|
|
18
|
+
"parse_attachment_descriptor_text",
|
|
19
|
+
"render_attachment_descriptor_text",
|
|
20
|
+
"resolve_messages_for_provider",
|
|
21
|
+
"sniff_mime",
|
|
22
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""In-memory session attachment catalog (rebuilt from history on load)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from monkeybot.core.llm.provider import Message
|
|
9
|
+
from monkeybot.core.types.content_blocks import Text
|
|
10
|
+
|
|
11
|
+
from .text import parse_attachment_descriptor_text
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class AttachmentRecord:
|
|
16
|
+
attachment_id: str
|
|
17
|
+
filename: str
|
|
18
|
+
mime_type: str
|
|
19
|
+
description: str
|
|
20
|
+
storage_path: str
|
|
21
|
+
uploaded_at_ms: int | None = None
|
|
22
|
+
file_missing: bool = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class SessionAttachmentCatalog:
|
|
27
|
+
"""Derived cache; history frozen Text lines are the durable source of truth."""
|
|
28
|
+
|
|
29
|
+
session_id: str
|
|
30
|
+
records: dict[str, AttachmentRecord] = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
def list_records(self) -> list[AttachmentRecord]:
|
|
33
|
+
return list(self.records.values())
|
|
34
|
+
|
|
35
|
+
def contains(self, attachment_id: str) -> bool:
|
|
36
|
+
return attachment_id in self.records
|
|
37
|
+
|
|
38
|
+
def get(self, attachment_id: str) -> AttachmentRecord | None:
|
|
39
|
+
return self.records.get(attachment_id)
|
|
40
|
+
|
|
41
|
+
def upsert(self, record: AttachmentRecord) -> None:
|
|
42
|
+
self.records[record.attachment_id] = record
|
|
43
|
+
|
|
44
|
+
def rebuild_from_history(self, messages: Sequence[Message]) -> None:
|
|
45
|
+
self.records.clear()
|
|
46
|
+
for msg in messages:
|
|
47
|
+
for block in msg.content:
|
|
48
|
+
if not isinstance(block, Text):
|
|
49
|
+
continue
|
|
50
|
+
parsed = parse_attachment_descriptor_text(block.text)
|
|
51
|
+
if parsed is None:
|
|
52
|
+
continue
|
|
53
|
+
storage_path = (
|
|
54
|
+
f".monkeybot/attachments/{self.session_id}/{parsed.attachment_id}"
|
|
55
|
+
)
|
|
56
|
+
self.records[parsed.attachment_id] = AttachmentRecord(
|
|
57
|
+
attachment_id=parsed.attachment_id,
|
|
58
|
+
filename=parsed.filename,
|
|
59
|
+
mime_type=parsed.mime_type,
|
|
60
|
+
description=parsed.description,
|
|
61
|
+
storage_path=storage_path,
|
|
62
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Attachment feature flags and limits from environment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
ALLOWED_MIME_TYPES: frozenset[str] = frozenset(
|
|
8
|
+
{
|
|
9
|
+
"image/jpeg",
|
|
10
|
+
"image/png",
|
|
11
|
+
"image/gif",
|
|
12
|
+
"image/webp",
|
|
13
|
+
"application/pdf",
|
|
14
|
+
}
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
IMAGE_MIME_TYPES: frozenset[str] = frozenset(
|
|
18
|
+
t for t in ALLOWED_MIME_TYPES if t.startswith("image/")
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def attachments_enabled_from_env() -> bool:
|
|
23
|
+
raw = os.getenv("ATTACHMENTS_ENABLED", "true").strip().lower()
|
|
24
|
+
return raw not in {"0", "false", "no", "off", ""}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _env_int(name: str, default: int) -> int:
|
|
28
|
+
raw = os.getenv(name, str(default)).strip()
|
|
29
|
+
try:
|
|
30
|
+
return max(0, int(raw))
|
|
31
|
+
except ValueError:
|
|
32
|
+
return default
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def max_image_bytes() -> int:
|
|
36
|
+
return _env_int("ATTACHMENT_MAX_IMAGE_BYTES", 20 * 1024 * 1024)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def max_pdf_bytes() -> int:
|
|
40
|
+
return _env_int("ATTACHMENT_MAX_PDF_BYTES", 50 * 1024 * 1024)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def max_attachments_per_session() -> int:
|
|
44
|
+
return max(1, _env_int("ATTACHMENT_MAX_PER_SESSION", 50))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def max_attachments_per_reply() -> int:
|
|
48
|
+
return max(1, _env_int("ATTACHMENT_MAX_PER_REPLY", 5))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def attachment_ttl_hours() -> int:
|
|
52
|
+
return max(1, _env_int("ATTACHMENT_TTL_HOURS", 48))
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Freeze attachment refs and tool-result media to Text in history."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from monkeybot.core.llm.provider import Message
|
|
8
|
+
from monkeybot.core.persistence.backends import HistoryStore
|
|
9
|
+
from monkeybot.core.runtime.events import AttachmentDescriptorEvent
|
|
10
|
+
from monkeybot.core.types.content_blocks import (
|
|
11
|
+
AttachmentRef,
|
|
12
|
+
ContentBlock,
|
|
13
|
+
File,
|
|
14
|
+
Image,
|
|
15
|
+
Text,
|
|
16
|
+
ToolResponse,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from .catalog import AttachmentRecord, SessionAttachmentCatalog
|
|
20
|
+
from .text import (
|
|
21
|
+
filename_from_metadata,
|
|
22
|
+
render_attachment_descriptor_text,
|
|
23
|
+
render_tool_media_freeze_text,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _freeze_user_row(
|
|
28
|
+
msg: Message,
|
|
29
|
+
*,
|
|
30
|
+
thread_id: str,
|
|
31
|
+
last_assistant_text: str,
|
|
32
|
+
catalog: SessionAttachmentCatalog | None,
|
|
33
|
+
events: list[AttachmentDescriptorEvent],
|
|
34
|
+
) -> Message:
|
|
35
|
+
user_text = " ".join(
|
|
36
|
+
b.text.strip() for b in msg.content if isinstance(b, Text) and b.text.strip()
|
|
37
|
+
)
|
|
38
|
+
new_content: list[ContentBlock] = []
|
|
39
|
+
for block in msg.content:
|
|
40
|
+
if isinstance(block, Text):
|
|
41
|
+
new_content.append(block)
|
|
42
|
+
continue
|
|
43
|
+
if not isinstance(block, AttachmentRef):
|
|
44
|
+
new_content.append(block)
|
|
45
|
+
continue
|
|
46
|
+
filename = filename_from_metadata(block.metadata, fallback=block.attachment_id)
|
|
47
|
+
description = last_assistant_text.strip() or user_text
|
|
48
|
+
if not description:
|
|
49
|
+
description = f"{filename} ({block.mime_type}) attached by user."
|
|
50
|
+
frozen_text = render_attachment_descriptor_text(
|
|
51
|
+
attachment_id=block.attachment_id,
|
|
52
|
+
mime_type=block.mime_type,
|
|
53
|
+
filename=filename,
|
|
54
|
+
description=description,
|
|
55
|
+
)
|
|
56
|
+
new_content.append(Text(text=frozen_text))
|
|
57
|
+
events.append(
|
|
58
|
+
AttachmentDescriptorEvent(
|
|
59
|
+
attachment_id=block.attachment_id,
|
|
60
|
+
mime_type=block.mime_type,
|
|
61
|
+
filename=filename,
|
|
62
|
+
description=description[:500],
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
if catalog is not None:
|
|
66
|
+
catalog.upsert(
|
|
67
|
+
AttachmentRecord(
|
|
68
|
+
attachment_id=block.attachment_id,
|
|
69
|
+
filename=filename,
|
|
70
|
+
mime_type=block.mime_type,
|
|
71
|
+
description=description[:500],
|
|
72
|
+
storage_path=f".monkeybot/attachments/{thread_id}/{block.attachment_id}",
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
return Message(role=msg.role, content=new_content)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _freeze_tool_responses(msg: Message) -> Message:
|
|
79
|
+
new_content: list[ContentBlock] = []
|
|
80
|
+
changed = False
|
|
81
|
+
for block in msg.content:
|
|
82
|
+
if not isinstance(block, ToolResponse):
|
|
83
|
+
new_content.append(block)
|
|
84
|
+
continue
|
|
85
|
+
if not any(isinstance(b, (Image, File)) for b in block.result):
|
|
86
|
+
new_content.append(block)
|
|
87
|
+
continue
|
|
88
|
+
kind = "image" if any(isinstance(b, Image) for b in block.result) else "file"
|
|
89
|
+
if any(
|
|
90
|
+
isinstance(b, File) and b.mime_type == "application/pdf" for b in block.result
|
|
91
|
+
):
|
|
92
|
+
kind = "pdf"
|
|
93
|
+
attachment_id: str | None = None
|
|
94
|
+
for b in block.result:
|
|
95
|
+
if isinstance(b, Image):
|
|
96
|
+
meta = b.metadata or {}
|
|
97
|
+
att_raw = meta.get("attachment_id")
|
|
98
|
+
if isinstance(att_raw, str) and att_raw.strip():
|
|
99
|
+
attachment_id = att_raw.strip()
|
|
100
|
+
break
|
|
101
|
+
summary = render_tool_media_freeze_text(
|
|
102
|
+
tool_name=block.tool_name,
|
|
103
|
+
attachment_id=attachment_id,
|
|
104
|
+
kind=kind,
|
|
105
|
+
)
|
|
106
|
+
changed = True
|
|
107
|
+
new_content.append(
|
|
108
|
+
ToolResponse(
|
|
109
|
+
id=block.id,
|
|
110
|
+
tool_name=block.tool_name,
|
|
111
|
+
result=[Text(text=summary)],
|
|
112
|
+
is_error=block.is_error,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
if not changed:
|
|
116
|
+
return msg
|
|
117
|
+
return Message(role=msg.role, content=new_content)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def freeze_attachments_in_history(
|
|
121
|
+
*,
|
|
122
|
+
thread_id: str,
|
|
123
|
+
history: HistoryStore,
|
|
124
|
+
catalog: SessionAttachmentCatalog | None,
|
|
125
|
+
last_assistant_text: str,
|
|
126
|
+
) -> list[AttachmentDescriptorEvent]:
|
|
127
|
+
"""Rewrite user attachmentRef rows and tool-result media to Text; return SSE events."""
|
|
128
|
+
rows = await history.load(thread_id)
|
|
129
|
+
mutated: list[Message] = []
|
|
130
|
+
events: list[AttachmentDescriptorEvent] = []
|
|
131
|
+
changed = False
|
|
132
|
+
|
|
133
|
+
for msg in rows:
|
|
134
|
+
frozen_msg = msg
|
|
135
|
+
if msg.role == "user" and any(isinstance(b, AttachmentRef) for b in msg.content):
|
|
136
|
+
frozen_msg = _freeze_user_row(
|
|
137
|
+
msg,
|
|
138
|
+
thread_id=thread_id,
|
|
139
|
+
last_assistant_text=last_assistant_text,
|
|
140
|
+
catalog=catalog,
|
|
141
|
+
events=events,
|
|
142
|
+
)
|
|
143
|
+
changed = True
|
|
144
|
+
tool_frozen = _freeze_tool_responses(frozen_msg)
|
|
145
|
+
if tool_frozen is not frozen_msg:
|
|
146
|
+
changed = True
|
|
147
|
+
mutated.append(tool_frozen)
|
|
148
|
+
|
|
149
|
+
if changed:
|
|
150
|
+
await history.reset(thread_id, mutated)
|
|
151
|
+
return events
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def ensure_catalog_from_history(
|
|
155
|
+
catalog: SessionAttachmentCatalog,
|
|
156
|
+
messages: Sequence[Message],
|
|
157
|
+
) -> None:
|
|
158
|
+
catalog.rebuild_from_history(messages)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Resolve attachmentRef blocks to Image/File for provider calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from monkeybot.core.llm.provider import Message
|
|
9
|
+
from monkeybot.core.types.content_blocks import (
|
|
10
|
+
AttachmentRef,
|
|
11
|
+
ContentBlock,
|
|
12
|
+
File,
|
|
13
|
+
Image,
|
|
14
|
+
Text,
|
|
15
|
+
ToolResponse,
|
|
16
|
+
)
|
|
17
|
+
from monkeybot.core.types.interfaces import MonkeybotError
|
|
18
|
+
|
|
19
|
+
from .config import IMAGE_MIME_TYPES
|
|
20
|
+
from .store import AttachmentStore
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AttachmentResolveError(MonkeybotError):
|
|
24
|
+
"""Failed to load attachment bytes for provider resolution."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _ref_to_media(
|
|
28
|
+
store: AttachmentStore,
|
|
29
|
+
session_id: str,
|
|
30
|
+
ref: AttachmentRef,
|
|
31
|
+
) -> Image | File:
|
|
32
|
+
try:
|
|
33
|
+
data_b64, mime, _filename = store.read_base64(session_id, ref.attachment_id)
|
|
34
|
+
except FileNotFoundError as exc:
|
|
35
|
+
raise AttachmentResolveError(str(exc)) from exc
|
|
36
|
+
mime_use = ref.mime_type or mime
|
|
37
|
+
meta = dict(ref.metadata) if ref.metadata else None
|
|
38
|
+
if mime_use in IMAGE_MIME_TYPES:
|
|
39
|
+
return Image(mime_type=mime_use, data=data_b64, metadata=meta)
|
|
40
|
+
return File(mime_type=mime_use, data=data_b64, metadata=meta)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _resolve_user_content(blocks: list[ContentBlock], store: AttachmentStore, session_id: str) -> list[ContentBlock]:
|
|
44
|
+
out: list[ContentBlock] = []
|
|
45
|
+
for block in blocks:
|
|
46
|
+
if isinstance(block, AttachmentRef):
|
|
47
|
+
out.append(_ref_to_media(store, session_id, block))
|
|
48
|
+
else:
|
|
49
|
+
out.append(block)
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def resolve_messages_for_provider(
|
|
54
|
+
messages: Sequence[Message],
|
|
55
|
+
*,
|
|
56
|
+
attachment_store: AttachmentStore | None,
|
|
57
|
+
session_id: str,
|
|
58
|
+
) -> list[Message]:
|
|
59
|
+
"""Return a copy of messages with live attachmentRef rows resolved to Image/File."""
|
|
60
|
+
if attachment_store is None:
|
|
61
|
+
return list(messages)
|
|
62
|
+
|
|
63
|
+
resolved: list[Message] = []
|
|
64
|
+
for msg in copy.deepcopy(list(messages)):
|
|
65
|
+
if msg.role != "user" or not any(isinstance(b, AttachmentRef) for b in msg.content):
|
|
66
|
+
resolved.append(msg)
|
|
67
|
+
continue
|
|
68
|
+
new_content = _resolve_user_content(list(msg.content), attachment_store, session_id)
|
|
69
|
+
resolved.append(Message(role=msg.role, content=new_content))
|
|
70
|
+
return resolved
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Filesystem-backed session attachment store."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
from monkeybot.core.path_safety import sanitize_path_component
|
|
14
|
+
from monkeybot.core.attachments.config import (
|
|
15
|
+
ALLOWED_MIME_TYPES,
|
|
16
|
+
IMAGE_MIME_TYPES,
|
|
17
|
+
attachment_ttl_hours,
|
|
18
|
+
max_attachments_per_session,
|
|
19
|
+
max_image_bytes,
|
|
20
|
+
max_pdf_bytes,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AttachmentStoreError(Exception):
|
|
25
|
+
"""Base error for attachment store operations."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AttachmentTooLargeError(AttachmentStoreError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UnsupportedAttachmentTypeError(AttachmentStoreError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AttachmentSessionLimitError(AttachmentStoreError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class StoredAttachment:
|
|
42
|
+
attachment_id: str
|
|
43
|
+
mime_type: str
|
|
44
|
+
size_bytes: int
|
|
45
|
+
filename: str
|
|
46
|
+
created_at_ms: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def sniff_mime(header: bytes) -> str | None:
|
|
50
|
+
if header[:8] == b"\x89PNG\r\n\x1a\n":
|
|
51
|
+
return "image/png"
|
|
52
|
+
if header[:3] == b"\xff\xd8\xff":
|
|
53
|
+
return "image/jpeg"
|
|
54
|
+
if header[:6] in (b"GIF87a", b"GIF89a"):
|
|
55
|
+
return "image/gif"
|
|
56
|
+
if len(header) >= 12 and header[:4] == b"RIFF" and header[8:12] == b"WEBP":
|
|
57
|
+
return "image/webp"
|
|
58
|
+
if header[:5] == b"%PDF-":
|
|
59
|
+
return "application/pdf"
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def new_attachment_id() -> str:
|
|
64
|
+
return f"att_{uuid.uuid4().hex}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AttachmentStore(Protocol):
|
|
68
|
+
def exists(self, session_id: str, attachment_id: str) -> bool: ...
|
|
69
|
+
|
|
70
|
+
def count_session(self, session_id: str) -> int: ...
|
|
71
|
+
|
|
72
|
+
def save(
|
|
73
|
+
self,
|
|
74
|
+
session_id: str,
|
|
75
|
+
*,
|
|
76
|
+
data: bytes,
|
|
77
|
+
mime_type: str,
|
|
78
|
+
filename: str,
|
|
79
|
+
) -> StoredAttachment: ...
|
|
80
|
+
|
|
81
|
+
def read(self, session_id: str, attachment_id: str) -> tuple[bytes, str, str]: ...
|
|
82
|
+
|
|
83
|
+
def read_base64(self, session_id: str, attachment_id: str) -> tuple[str, str, str]: ...
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class FilesystemAttachmentStore:
|
|
87
|
+
def __init__(self, workspace_root: Path) -> None:
|
|
88
|
+
self._root = workspace_root.resolve()
|
|
89
|
+
|
|
90
|
+
def _session_dir(self, session_id: str) -> Path:
|
|
91
|
+
return self._root / ".monkeybot" / "attachments" / sanitize_path_component(session_id)
|
|
92
|
+
|
|
93
|
+
def _path_for(self, session_id: str, attachment_id: str) -> Path:
|
|
94
|
+
return self._session_dir(session_id) / sanitize_path_component(attachment_id)
|
|
95
|
+
|
|
96
|
+
def exists(self, session_id: str, attachment_id: str) -> bool:
|
|
97
|
+
return self._path_for(session_id, attachment_id).is_file()
|
|
98
|
+
|
|
99
|
+
def count_session(self, session_id: str) -> int:
|
|
100
|
+
d = self._session_dir(session_id)
|
|
101
|
+
if not d.is_dir():
|
|
102
|
+
return 0
|
|
103
|
+
return sum(1 for p in d.iterdir() if p.is_file() and not p.name.endswith(".json"))
|
|
104
|
+
|
|
105
|
+
def save(
|
|
106
|
+
self,
|
|
107
|
+
session_id: str,
|
|
108
|
+
*,
|
|
109
|
+
data: bytes,
|
|
110
|
+
mime_type: str,
|
|
111
|
+
filename: str,
|
|
112
|
+
) -> StoredAttachment:
|
|
113
|
+
if mime_type not in ALLOWED_MIME_TYPES:
|
|
114
|
+
raise UnsupportedAttachmentTypeError(f"unsupported mime type: {mime_type}")
|
|
115
|
+
max_bytes = max_image_bytes() if mime_type in IMAGE_MIME_TYPES else max_pdf_bytes()
|
|
116
|
+
if len(data) > max_bytes:
|
|
117
|
+
raise AttachmentTooLargeError(f"attachment exceeds {max_bytes} bytes")
|
|
118
|
+
sniffed = sniff_mime(data[:512])
|
|
119
|
+
if sniffed is not None and sniffed != mime_type:
|
|
120
|
+
raise UnsupportedAttachmentTypeError(
|
|
121
|
+
f"declared mime {mime_type!r} does not match content ({sniffed})"
|
|
122
|
+
)
|
|
123
|
+
if self.count_session(session_id) >= max_attachments_per_session():
|
|
124
|
+
raise AttachmentSessionLimitError("session attachment limit reached")
|
|
125
|
+
|
|
126
|
+
attachment_id = new_attachment_id()
|
|
127
|
+
path = self._path_for(session_id, attachment_id)
|
|
128
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
path.write_bytes(data)
|
|
130
|
+
created_at_ms = int(time.time() * 1000)
|
|
131
|
+
meta_path = path.with_suffix(path.suffix + ".json")
|
|
132
|
+
meta_path.write_text(
|
|
133
|
+
json.dumps(
|
|
134
|
+
{
|
|
135
|
+
"attachment_id": attachment_id,
|
|
136
|
+
"mime_type": mime_type,
|
|
137
|
+
"filename": filename,
|
|
138
|
+
"size_bytes": len(data),
|
|
139
|
+
"created_at_ms": created_at_ms,
|
|
140
|
+
}
|
|
141
|
+
),
|
|
142
|
+
encoding="utf-8",
|
|
143
|
+
)
|
|
144
|
+
return StoredAttachment(
|
|
145
|
+
attachment_id=attachment_id,
|
|
146
|
+
mime_type=mime_type,
|
|
147
|
+
size_bytes=len(data),
|
|
148
|
+
filename=filename,
|
|
149
|
+
created_at_ms=created_at_ms,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def read(self, session_id: str, attachment_id: str) -> tuple[bytes, str, str]:
|
|
153
|
+
path = self._path_for(session_id, attachment_id)
|
|
154
|
+
if not path.is_file():
|
|
155
|
+
raise FileNotFoundError(f"attachment not found: {attachment_id}")
|
|
156
|
+
self._check_ttl(path)
|
|
157
|
+
meta = self._read_meta(path)
|
|
158
|
+
mime = str(meta.get("mime_type", "application/octet-stream"))
|
|
159
|
+
filename = str(meta.get("filename", attachment_id))
|
|
160
|
+
return path.read_bytes(), mime, filename
|
|
161
|
+
|
|
162
|
+
def read_base64(self, session_id: str, attachment_id: str) -> tuple[str, str, str]:
|
|
163
|
+
data, mime, filename = self.read(session_id, attachment_id)
|
|
164
|
+
return base64.b64encode(data).decode("ascii"), mime, filename
|
|
165
|
+
|
|
166
|
+
def _read_meta(self, path: Path) -> dict[str, object]:
|
|
167
|
+
meta_path = path.with_suffix(path.suffix + ".json")
|
|
168
|
+
if meta_path.is_file():
|
|
169
|
+
raw = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
170
|
+
if isinstance(raw, dict):
|
|
171
|
+
return raw
|
|
172
|
+
return {}
|
|
173
|
+
|
|
174
|
+
def _check_ttl(self, path: Path) -> None:
|
|
175
|
+
meta = self._read_meta(path)
|
|
176
|
+
created = meta.get("created_at_ms")
|
|
177
|
+
if isinstance(created, (int, float)):
|
|
178
|
+
age_ms = int(time.time() * 1000) - int(created)
|
|
179
|
+
if age_ms > attachment_ttl_hours() * 3600 * 1000:
|
|
180
|
+
raise FileNotFoundError("attachment expired")
|