opencomputer 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.
- opencomputer/__init__.py +3 -0
- opencomputer/agent/__init__.py +1 -0
- opencomputer/agent/compaction.py +245 -0
- opencomputer/agent/config.py +108 -0
- opencomputer/agent/config_store.py +210 -0
- opencomputer/agent/injection.py +60 -0
- opencomputer/agent/loop.py +326 -0
- opencomputer/agent/memory.py +132 -0
- opencomputer/agent/prompt_builder.py +66 -0
- opencomputer/agent/prompts/base.j2 +23 -0
- opencomputer/agent/state.py +251 -0
- opencomputer/agent/step.py +31 -0
- opencomputer/cli.py +483 -0
- opencomputer/doctor.py +216 -0
- opencomputer/gateway/__init__.py +1 -0
- opencomputer/gateway/dispatch.py +89 -0
- opencomputer/gateway/protocol.py +84 -0
- opencomputer/gateway/server.py +77 -0
- opencomputer/gateway/wire_server.py +256 -0
- opencomputer/hooks/__init__.py +1 -0
- opencomputer/hooks/engine.py +79 -0
- opencomputer/hooks/runner.py +42 -0
- opencomputer/mcp/__init__.py +1 -0
- opencomputer/mcp/client.py +208 -0
- opencomputer/plugins/__init__.py +1 -0
- opencomputer/plugins/discovery.py +107 -0
- opencomputer/plugins/loader.py +155 -0
- opencomputer/plugins/registry.py +56 -0
- opencomputer/setup_wizard.py +235 -0
- opencomputer/skills/debug-python-import-error/SKILL.md +58 -0
- opencomputer/tools/__init__.py +1 -0
- opencomputer/tools/bash.py +78 -0
- opencomputer/tools/delegate.py +98 -0
- opencomputer/tools/glob.py +70 -0
- opencomputer/tools/grep.py +117 -0
- opencomputer/tools/read.py +81 -0
- opencomputer/tools/registry.py +69 -0
- opencomputer/tools/skill_manage.py +265 -0
- opencomputer/tools/write.py +58 -0
- opencomputer-0.1.0.dist-info/METADATA +190 -0
- opencomputer-0.1.0.dist-info/RECORD +51 -0
- opencomputer-0.1.0.dist-info/WHEEL +4 -0
- opencomputer-0.1.0.dist-info/entry_points.txt +3 -0
- plugin_sdk/__init__.py +66 -0
- plugin_sdk/channel_contract.py +74 -0
- plugin_sdk/core.py +129 -0
- plugin_sdk/hooks.py +80 -0
- plugin_sdk/injection.py +60 -0
- plugin_sdk/provider_contract.py +95 -0
- plugin_sdk/runtime_context.py +39 -0
- plugin_sdk/tool_contract.py +67 -0
opencomputer/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Agent subsystem — loop, memory, state, prompt building."""
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CompactionEngine — auto-summarize old turns when the context fills up.
|
|
3
|
+
|
|
4
|
+
Design notes (per Phase 6a review):
|
|
5
|
+
|
|
6
|
+
1. Trigger uses the ACTUAL input_tokens from the last ProviderResponse.usage
|
|
7
|
+
(not a character-count estimate). Different providers tokenize differently.
|
|
8
|
+
2. Preserves the last N messages (default 20) untouched.
|
|
9
|
+
3. Preserves assistant+tool_result message PAIRS atomically. Splitting a
|
|
10
|
+
tool_use from its matching tool_result causes Anthropic's API to 400.
|
|
11
|
+
4. On aux-LLM failure or timeout, falls back to a deterministic
|
|
12
|
+
"truncate-and-drop-oldest-N" strategy so the turn can still proceed.
|
|
13
|
+
5. Hooks and injection providers DO NOT fire inside the compaction LLM call
|
|
14
|
+
(no recursion). Iteration budget is not charged.
|
|
15
|
+
|
|
16
|
+
Returns a NEW message list with the compacted range replaced by one synthetic
|
|
17
|
+
assistant message tagged `[compacted-summary]` so downstream tools (FTS5
|
|
18
|
+
search) can distinguish it from the model's own output.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import logging
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
from plugin_sdk.core import Message
|
|
28
|
+
from plugin_sdk.provider_contract import BaseProvider
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("opencomputer.agent.compaction")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
#: Sensible per-model-family context windows. Compaction fires at 80% of these.
|
|
34
|
+
#: Keep conservative — better to compact early than hit a real-limit error.
|
|
35
|
+
DEFAULT_CONTEXT_WINDOWS: dict[str, int] = {
|
|
36
|
+
# Anthropic Claude 4.x models with extended context
|
|
37
|
+
"claude-opus-4-7": 200_000,
|
|
38
|
+
"claude-sonnet-4-6": 200_000,
|
|
39
|
+
"claude-haiku-4-5": 200_000,
|
|
40
|
+
# OpenAI GPT 5.x
|
|
41
|
+
"gpt-5.4": 400_000,
|
|
42
|
+
# Fallback
|
|
43
|
+
"_default": 200_000,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class CompactionConfig:
|
|
49
|
+
preserve_recent: int = 20
|
|
50
|
+
threshold_ratio: float = 0.8
|
|
51
|
+
summarize_max_tokens: int = 1024
|
|
52
|
+
summarize_timeout_s: float = 30.0
|
|
53
|
+
#: Number of messages to drop on aux-LLM failure fallback.
|
|
54
|
+
fallback_drop_count: int = 10
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(slots=True)
|
|
58
|
+
class CompactionResult:
|
|
59
|
+
messages: list[Message]
|
|
60
|
+
did_compact: bool = False
|
|
61
|
+
degraded: bool = False # True when aux LLM failed and we truncated instead
|
|
62
|
+
reason: str = ""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def context_window_for(model: str) -> int:
|
|
66
|
+
"""Look up the context window for a model. Falls back to default."""
|
|
67
|
+
if model in DEFAULT_CONTEXT_WINDOWS:
|
|
68
|
+
return DEFAULT_CONTEXT_WINDOWS[model]
|
|
69
|
+
# Fuzzy family match
|
|
70
|
+
for key, v in DEFAULT_CONTEXT_WINDOWS.items():
|
|
71
|
+
if key != "_default" and model.startswith(key.split("-")[0]):
|
|
72
|
+
return v
|
|
73
|
+
return DEFAULT_CONTEXT_WINDOWS["_default"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class CompactionEngine:
|
|
77
|
+
"""Decide when to compact, and do it with safety rails."""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
provider: BaseProvider,
|
|
82
|
+
model: str,
|
|
83
|
+
config: CompactionConfig | None = None,
|
|
84
|
+
disabled: bool = False,
|
|
85
|
+
) -> None:
|
|
86
|
+
self.provider = provider
|
|
87
|
+
self.model = model
|
|
88
|
+
self.config = config or CompactionConfig()
|
|
89
|
+
self.disabled = disabled
|
|
90
|
+
#: Flag the loop checks to suppress hook firing while compaction runs.
|
|
91
|
+
self._in_progress = False
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def in_progress(self) -> bool:
|
|
95
|
+
"""True while compaction's own LLM call is in flight — hooks must not fire."""
|
|
96
|
+
return self._in_progress
|
|
97
|
+
|
|
98
|
+
def should_compact(self, last_input_tokens: int) -> bool:
|
|
99
|
+
"""Use actual measured tokens, not an estimate."""
|
|
100
|
+
if self.disabled:
|
|
101
|
+
return False
|
|
102
|
+
window = context_window_for(self.model)
|
|
103
|
+
threshold = int(window * self.config.threshold_ratio)
|
|
104
|
+
return last_input_tokens >= threshold
|
|
105
|
+
|
|
106
|
+
async def maybe_run(
|
|
107
|
+
self, messages: list[Message], last_input_tokens: int
|
|
108
|
+
) -> CompactionResult:
|
|
109
|
+
"""Check the threshold; compact if needed; otherwise return unchanged."""
|
|
110
|
+
if not self.should_compact(last_input_tokens):
|
|
111
|
+
return CompactionResult(messages=messages, did_compact=False)
|
|
112
|
+
|
|
113
|
+
# Decide which messages to compact. Preserve:
|
|
114
|
+
# - System messages at the start
|
|
115
|
+
# - The last N messages untouched
|
|
116
|
+
recent_count = self.config.preserve_recent
|
|
117
|
+
if len(messages) <= recent_count + 1:
|
|
118
|
+
# Not enough old messages to bother — no-op
|
|
119
|
+
return CompactionResult(messages=messages, did_compact=False)
|
|
120
|
+
|
|
121
|
+
# Split at a SAFE boundary — must not split tool_use from tool_result.
|
|
122
|
+
split_idx = self._safe_split_index(messages, recent_count)
|
|
123
|
+
if split_idx <= 0:
|
|
124
|
+
return CompactionResult(messages=messages, did_compact=False)
|
|
125
|
+
|
|
126
|
+
old_block = messages[:split_idx]
|
|
127
|
+
recent_block = messages[split_idx:]
|
|
128
|
+
|
|
129
|
+
# Try the aux LLM summary
|
|
130
|
+
try:
|
|
131
|
+
summary_text = await asyncio.wait_for(
|
|
132
|
+
self._summarize(old_block), timeout=self.config.summarize_timeout_s
|
|
133
|
+
)
|
|
134
|
+
except Exception as e: # noqa: BLE001 — fall back on any failure
|
|
135
|
+
logger.warning("compaction aux LLM failed, falling back to truncate: %s", e)
|
|
136
|
+
return self._truncate_fallback(messages, split_idx)
|
|
137
|
+
|
|
138
|
+
# Success — replace old_block with one synthetic summary message
|
|
139
|
+
synthetic = Message(
|
|
140
|
+
role="assistant",
|
|
141
|
+
content=f"[compacted-summary]\n\n{summary_text}",
|
|
142
|
+
)
|
|
143
|
+
new_msgs = [synthetic, *recent_block]
|
|
144
|
+
return CompactionResult(messages=new_msgs, did_compact=True, reason="aux-summary")
|
|
145
|
+
|
|
146
|
+
# ─── internals ────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
def _safe_split_index(
|
|
149
|
+
self, messages: list[Message], preserve_recent: int
|
|
150
|
+
) -> int:
|
|
151
|
+
"""
|
|
152
|
+
Find a split point at `len(messages) - preserve_recent` that does NOT
|
|
153
|
+
break a tool_use / tool_result pair.
|
|
154
|
+
|
|
155
|
+
Walk backwards from the target index. If the candidate boundary has
|
|
156
|
+
a tool_result right after a tool_use, move earlier until we're between
|
|
157
|
+
a clean turn boundary.
|
|
158
|
+
"""
|
|
159
|
+
if len(messages) <= preserve_recent:
|
|
160
|
+
return 0
|
|
161
|
+
target = len(messages) - preserve_recent
|
|
162
|
+
|
|
163
|
+
# Scan backward: if messages[target] is a tool result, move back until
|
|
164
|
+
# we land right BEFORE its originating assistant tool_use message
|
|
165
|
+
# (ideally at a user message or a clean assistant reply).
|
|
166
|
+
idx = target
|
|
167
|
+
while idx > 0:
|
|
168
|
+
msg = messages[idx]
|
|
169
|
+
prev = messages[idx - 1] if idx > 0 else None
|
|
170
|
+
# Unsafe: `idx` points to a tool result and prev is an assistant
|
|
171
|
+
# message containing tool_use blocks — splitting would orphan them.
|
|
172
|
+
prev_has_tool_use = (
|
|
173
|
+
prev is not None
|
|
174
|
+
and prev.role == "assistant"
|
|
175
|
+
and bool(prev.tool_calls)
|
|
176
|
+
)
|
|
177
|
+
if msg.role == "tool" or prev_has_tool_use:
|
|
178
|
+
idx -= 1
|
|
179
|
+
continue
|
|
180
|
+
break
|
|
181
|
+
return idx
|
|
182
|
+
|
|
183
|
+
async def _summarize(self, old_block: list[Message]) -> str:
|
|
184
|
+
"""Call the provider to summarize. Hooks/injection must NOT fire here."""
|
|
185
|
+
self._in_progress = True
|
|
186
|
+
try:
|
|
187
|
+
# Keep the prompt simple. The provider returns a plain Message.
|
|
188
|
+
prompt = Message(
|
|
189
|
+
role="user",
|
|
190
|
+
content=(
|
|
191
|
+
"Summarize the following conversation history tightly. "
|
|
192
|
+
"Keep facts, decisions, file paths, and any commands run. "
|
|
193
|
+
"Output plain prose, no markdown headers. Target ~300 words."
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
# Flatten history into text — providers need canonical messages.
|
|
197
|
+
synth_history = _flatten_for_summary(old_block)
|
|
198
|
+
resp = await self.provider.complete(
|
|
199
|
+
model=self.model,
|
|
200
|
+
messages=[Message(role="user", content=synth_history), prompt],
|
|
201
|
+
max_tokens=self.config.summarize_max_tokens,
|
|
202
|
+
temperature=0.3,
|
|
203
|
+
)
|
|
204
|
+
return resp.message.content or "[compaction returned empty]"
|
|
205
|
+
finally:
|
|
206
|
+
self._in_progress = False
|
|
207
|
+
|
|
208
|
+
def _truncate_fallback(
|
|
209
|
+
self, messages: list[Message], split_idx: int
|
|
210
|
+
) -> CompactionResult:
|
|
211
|
+
"""Degraded path: drop N oldest non-system messages."""
|
|
212
|
+
drop = min(self.config.fallback_drop_count, split_idx)
|
|
213
|
+
new_msgs = messages[drop:]
|
|
214
|
+
synthetic = Message(
|
|
215
|
+
role="assistant",
|
|
216
|
+
content=f"[compacted-truncated] — {drop} oldest messages removed due to compaction failure",
|
|
217
|
+
)
|
|
218
|
+
return CompactionResult(
|
|
219
|
+
messages=[synthetic, *new_msgs],
|
|
220
|
+
did_compact=True,
|
|
221
|
+
degraded=True,
|
|
222
|
+
reason="aux-failed-truncated",
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _flatten_for_summary(messages: list[Message]) -> str:
|
|
227
|
+
"""Render a message list as plain text for the summarizer."""
|
|
228
|
+
parts: list[str] = []
|
|
229
|
+
for m in messages:
|
|
230
|
+
role = m.role.upper()
|
|
231
|
+
content = m.content or ""
|
|
232
|
+
if m.tool_calls:
|
|
233
|
+
tool_names = ", ".join(tc.name for tc in m.tool_calls)
|
|
234
|
+
content = (content + f"\n[called tools: {tool_names}]").strip()
|
|
235
|
+
parts.append(f"{role}: {content}")
|
|
236
|
+
return "\n\n".join(parts)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
__all__ = [
|
|
240
|
+
"CompactionEngine",
|
|
241
|
+
"CompactionConfig",
|
|
242
|
+
"CompactionResult",
|
|
243
|
+
"context_window_for",
|
|
244
|
+
"DEFAULT_CONTEXT_WINDOWS",
|
|
245
|
+
]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Typed configuration — replaces the 58-parameter __init__ nightmare.
|
|
3
|
+
|
|
4
|
+
All agent config lives in small, composable dataclasses. Load from
|
|
5
|
+
~/.opencomputer/config.yaml (or TOML — TBD). Environment variables
|
|
6
|
+
can override individual fields.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _home() -> Path:
|
|
17
|
+
"""Return ~/.opencomputer/, creating it if needed."""
|
|
18
|
+
home = Path(os.environ.get("OPENCOMPUTER_HOME", Path.home() / ".opencomputer"))
|
|
19
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
return home
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class ModelConfig:
|
|
25
|
+
"""Which LLM to use and how."""
|
|
26
|
+
|
|
27
|
+
provider: str = "anthropic" # maps to a provider plugin name
|
|
28
|
+
model: str = "claude-opus-4-7"
|
|
29
|
+
max_tokens: int = 4096
|
|
30
|
+
temperature: float = 1.0
|
|
31
|
+
api_key_env: str = "ANTHROPIC_API_KEY"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class LoopConfig:
|
|
36
|
+
"""Behavior of the main agent loop."""
|
|
37
|
+
|
|
38
|
+
max_iterations: int = 50
|
|
39
|
+
parallel_tools: bool = True
|
|
40
|
+
iteration_timeout_s: int = 600
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class SessionConfig:
|
|
45
|
+
"""Where sessions are stored and how."""
|
|
46
|
+
|
|
47
|
+
db_path: Path = field(default_factory=lambda: _home() / "sessions.db")
|
|
48
|
+
session_id: str | None = None # None = create new session each run
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class MemoryConfig:
|
|
53
|
+
"""The three-pillar memory configuration."""
|
|
54
|
+
|
|
55
|
+
declarative_path: Path = field(default_factory=lambda: _home() / "MEMORY.md")
|
|
56
|
+
skills_path: Path = field(default_factory=lambda: _home() / "skills")
|
|
57
|
+
# episodic memory uses SessionConfig.db_path
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class MCPServerConfig:
|
|
62
|
+
"""One MCP server the agent should connect to."""
|
|
63
|
+
|
|
64
|
+
name: str = ""
|
|
65
|
+
transport: str = "stdio" # "stdio" or "http"
|
|
66
|
+
command: str = "" # for stdio: the executable (e.g. "python3")
|
|
67
|
+
args: tuple[str, ...] = () # for stdio: argv (use tuple for hashability)
|
|
68
|
+
url: str = "" # for http: endpoint URL
|
|
69
|
+
env: dict[str, str] = field(default_factory=dict) # optional env vars
|
|
70
|
+
enabled: bool = True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class MCPConfig:
|
|
75
|
+
"""MCP integration — list of servers + global toggles."""
|
|
76
|
+
|
|
77
|
+
servers: tuple[MCPServerConfig, ...] = ()
|
|
78
|
+
# Connect servers in the background after startup (kimi-cli pattern).
|
|
79
|
+
deferred: bool = True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class Config:
|
|
84
|
+
"""Root configuration — composed of small focused configs."""
|
|
85
|
+
|
|
86
|
+
model: ModelConfig = field(default_factory=ModelConfig)
|
|
87
|
+
loop: LoopConfig = field(default_factory=LoopConfig)
|
|
88
|
+
session: SessionConfig = field(default_factory=SessionConfig)
|
|
89
|
+
memory: MemoryConfig = field(default_factory=MemoryConfig)
|
|
90
|
+
mcp: MCPConfig = field(default_factory=MCPConfig)
|
|
91
|
+
home: Path = field(default_factory=_home)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def default_config() -> Config:
|
|
95
|
+
"""Return the default configuration with filesystem-appropriate paths."""
|
|
96
|
+
return Config()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
__all__ = [
|
|
100
|
+
"Config",
|
|
101
|
+
"ModelConfig",
|
|
102
|
+
"LoopConfig",
|
|
103
|
+
"SessionConfig",
|
|
104
|
+
"MemoryConfig",
|
|
105
|
+
"MCPConfig",
|
|
106
|
+
"MCPServerConfig",
|
|
107
|
+
"default_config",
|
|
108
|
+
]
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Config persistence — load/save ~/.opencomputer/config.yaml.
|
|
3
|
+
|
|
4
|
+
Users can edit this file by hand or via `opencomputer config set key=value`.
|
|
5
|
+
Defaults from ModelConfig/LoopConfig/etc. apply if the file is missing
|
|
6
|
+
or if a given key isn't set.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import asdict, fields, is_dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from opencomputer.agent.config import (
|
|
18
|
+
Config,
|
|
19
|
+
_home,
|
|
20
|
+
default_config,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def config_file_path() -> Path:
|
|
25
|
+
return _home() / "config.yaml"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ─── load ────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _apply_overrides(base: Any, overrides: dict[str, Any]) -> Any:
|
|
32
|
+
"""Recursively apply a dict over a dataclass, returning a new dataclass."""
|
|
33
|
+
if not is_dataclass(base) or not isinstance(overrides, dict):
|
|
34
|
+
return base
|
|
35
|
+
field_map = {f.name: f for f in fields(base)}
|
|
36
|
+
kwargs: dict[str, Any] = {}
|
|
37
|
+
for name, current in asdict(base).items():
|
|
38
|
+
if name in overrides:
|
|
39
|
+
new = overrides[name]
|
|
40
|
+
nested = getattr(base, name)
|
|
41
|
+
|
|
42
|
+
if is_dataclass(nested) and isinstance(new, dict):
|
|
43
|
+
# Nested dataclass (e.g. model, loop, mcp)
|
|
44
|
+
kwargs[name] = _apply_overrides(nested, new)
|
|
45
|
+
elif isinstance(nested, tuple) and isinstance(new, list):
|
|
46
|
+
# Tuple-of-dataclasses field (e.g. mcp.servers = [MCPServerConfig, ...])
|
|
47
|
+
inner_type = _extract_tuple_inner_type(type(base), name, nested)
|
|
48
|
+
if inner_type is not None:
|
|
49
|
+
built = []
|
|
50
|
+
for item in new:
|
|
51
|
+
if isinstance(item, dict):
|
|
52
|
+
# build a default instance then apply overrides
|
|
53
|
+
try:
|
|
54
|
+
default_instance = inner_type()
|
|
55
|
+
except TypeError:
|
|
56
|
+
default_instance = None
|
|
57
|
+
if default_instance is not None:
|
|
58
|
+
built.append(_apply_overrides(default_instance, item))
|
|
59
|
+
else:
|
|
60
|
+
built.append(item)
|
|
61
|
+
else:
|
|
62
|
+
built.append(item)
|
|
63
|
+
kwargs[name] = tuple(built)
|
|
64
|
+
else:
|
|
65
|
+
kwargs[name] = tuple(new)
|
|
66
|
+
else:
|
|
67
|
+
field_type = field_map[name].type
|
|
68
|
+
if "Path" in str(field_type) and isinstance(new, str):
|
|
69
|
+
kwargs[name] = Path(new)
|
|
70
|
+
else:
|
|
71
|
+
kwargs[name] = new
|
|
72
|
+
else:
|
|
73
|
+
kwargs[name] = getattr(base, name)
|
|
74
|
+
return type(base)(**kwargs)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _extract_tuple_inner_type(
|
|
78
|
+
base_cls: type, field_name: str, existing_tuple: tuple
|
|
79
|
+
) -> type | None:
|
|
80
|
+
"""Best-effort: figure out the dataclass type stored in a tuple field.
|
|
81
|
+
|
|
82
|
+
Uses typing.get_type_hints so 'from __future__ import annotations'
|
|
83
|
+
string annotations are resolved to real types.
|
|
84
|
+
"""
|
|
85
|
+
if existing_tuple and is_dataclass(existing_tuple[0]):
|
|
86
|
+
return type(existing_tuple[0])
|
|
87
|
+
import typing
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
hints = typing.get_type_hints(base_cls)
|
|
91
|
+
except Exception:
|
|
92
|
+
return None
|
|
93
|
+
annotation = hints.get(field_name)
|
|
94
|
+
if annotation is None:
|
|
95
|
+
return None
|
|
96
|
+
origin = typing.get_origin(annotation)
|
|
97
|
+
if origin is tuple:
|
|
98
|
+
args = typing.get_args(annotation)
|
|
99
|
+
if args and is_dataclass(args[0]):
|
|
100
|
+
return args[0]
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load_config(path: Path | None = None) -> Config:
|
|
105
|
+
"""Load config from YAML, applying overrides on top of defaults.
|
|
106
|
+
|
|
107
|
+
Missing file or empty file → returns defaults. Invalid YAML is an error.
|
|
108
|
+
"""
|
|
109
|
+
cfg_path = path or config_file_path()
|
|
110
|
+
base = default_config()
|
|
111
|
+
if not cfg_path.exists():
|
|
112
|
+
return base
|
|
113
|
+
try:
|
|
114
|
+
raw = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
|
|
115
|
+
except yaml.YAMLError as e:
|
|
116
|
+
raise RuntimeError(f"Failed to parse {cfg_path}: {e}") from e
|
|
117
|
+
if not isinstance(raw, dict):
|
|
118
|
+
raise RuntimeError(f"Config file {cfg_path} must be a YAML mapping")
|
|
119
|
+
return _apply_overrides(base, raw)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ─── save ────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _to_yaml_dict(cfg: Config) -> dict[str, Any]:
|
|
126
|
+
"""Convert a Config dataclass to a YAML-friendly dict (Paths as strings)."""
|
|
127
|
+
|
|
128
|
+
def _encode(v: Any) -> Any:
|
|
129
|
+
if isinstance(v, Path):
|
|
130
|
+
return str(v)
|
|
131
|
+
if is_dataclass(v):
|
|
132
|
+
return {k: _encode(getattr(v, k)) for k in [f.name for f in fields(v)]}
|
|
133
|
+
if isinstance(v, tuple):
|
|
134
|
+
return [_encode(item) for item in v]
|
|
135
|
+
return v
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
"model": _encode(cfg.model),
|
|
139
|
+
"loop": _encode(cfg.loop),
|
|
140
|
+
"session": _encode(cfg.session),
|
|
141
|
+
"memory": _encode(cfg.memory),
|
|
142
|
+
"mcp": _encode(cfg.mcp),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def save_config(cfg: Config, path: Path | None = None) -> Path:
|
|
147
|
+
"""Write config to YAML. Returns the path written."""
|
|
148
|
+
cfg_path = path or config_file_path()
|
|
149
|
+
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
data = _to_yaml_dict(cfg)
|
|
151
|
+
cfg_path.write_text(
|
|
152
|
+
yaml.safe_dump(data, default_flow_style=False, sort_keys=False),
|
|
153
|
+
encoding="utf-8",
|
|
154
|
+
)
|
|
155
|
+
return cfg_path
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ─── get / set by dotted key ────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def get_value(cfg: Config, key: str) -> Any:
|
|
162
|
+
"""Get a value by dotted key like 'model.provider'."""
|
|
163
|
+
parts = key.split(".")
|
|
164
|
+
current: Any = cfg
|
|
165
|
+
for p in parts:
|
|
166
|
+
if is_dataclass(current):
|
|
167
|
+
if not hasattr(current, p):
|
|
168
|
+
raise KeyError(f"Unknown config key: {key} (failed at '{p}')")
|
|
169
|
+
current = getattr(current, p)
|
|
170
|
+
else:
|
|
171
|
+
raise KeyError(f"Unknown config key: {key} (not a config section at '{p}')")
|
|
172
|
+
return current
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def set_value(cfg: Config, key: str, value: Any) -> Config:
|
|
176
|
+
"""Return a NEW Config with `key` set to `value`. Dotted key supported."""
|
|
177
|
+
parts = key.split(".")
|
|
178
|
+
if len(parts) == 1:
|
|
179
|
+
raise KeyError("Top-level set not supported: use e.g. 'model.provider'")
|
|
180
|
+
section_name, *rest = parts
|
|
181
|
+
if not hasattr(cfg, section_name):
|
|
182
|
+
raise KeyError(f"Unknown section: {section_name}")
|
|
183
|
+
|
|
184
|
+
section = getattr(cfg, section_name)
|
|
185
|
+
if not is_dataclass(section):
|
|
186
|
+
raise KeyError(f"'{section_name}' is not a config section")
|
|
187
|
+
|
|
188
|
+
# Descend into nested sections (rare in this flat schema but future-proof)
|
|
189
|
+
section_overrides: dict[str, Any] = {}
|
|
190
|
+
cursor = section_overrides
|
|
191
|
+
for i, p in enumerate(rest):
|
|
192
|
+
if i == len(rest) - 1:
|
|
193
|
+
cursor[p] = value
|
|
194
|
+
else:
|
|
195
|
+
cursor[p] = {}
|
|
196
|
+
cursor = cursor[p]
|
|
197
|
+
|
|
198
|
+
new_section = _apply_overrides(section, section_overrides)
|
|
199
|
+
kwargs = {f.name: getattr(cfg, f.name) for f in fields(cfg)}
|
|
200
|
+
kwargs[section_name] = new_section
|
|
201
|
+
return Config(**kwargs)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
__all__ = [
|
|
205
|
+
"config_file_path",
|
|
206
|
+
"load_config",
|
|
207
|
+
"save_config",
|
|
208
|
+
"get_value",
|
|
209
|
+
"set_value",
|
|
210
|
+
]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
InjectionEngine — queries all registered DynamicInjectionProviders per turn.
|
|
3
|
+
|
|
4
|
+
Deterministic ordering (priority asc, then provider_id asc) so repeated turns
|
|
5
|
+
with the same state produce the same system prompt. Critical for prompt cache
|
|
6
|
+
stability on the LLM side.
|
|
7
|
+
|
|
8
|
+
Providers register via `opencomputer.plugins.loader.PluginAPI.register_injection_provider`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from plugin_sdk.injection import DynamicInjectionProvider, InjectionContext
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InjectionEngine:
|
|
17
|
+
"""Singleton registry + composer for injection providers."""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self._providers: dict[str, DynamicInjectionProvider] = {}
|
|
21
|
+
|
|
22
|
+
def register(self, provider: DynamicInjectionProvider) -> None:
|
|
23
|
+
pid = provider.provider_id
|
|
24
|
+
if pid in self._providers:
|
|
25
|
+
raise ValueError(f"Injection provider '{pid}' already registered")
|
|
26
|
+
self._providers[pid] = provider
|
|
27
|
+
|
|
28
|
+
def unregister(self, provider_id: str) -> None:
|
|
29
|
+
self._providers.pop(provider_id, None)
|
|
30
|
+
|
|
31
|
+
def providers(self) -> list[DynamicInjectionProvider]:
|
|
32
|
+
return list(self._providers.values())
|
|
33
|
+
|
|
34
|
+
def collect(self, ctx: InjectionContext) -> list[str]:
|
|
35
|
+
"""Call each provider, return non-empty injections in deterministic order."""
|
|
36
|
+
# Deterministic: sort by (priority, provider_id). Same inputs → same output.
|
|
37
|
+
ordered = sorted(
|
|
38
|
+
self._providers.values(),
|
|
39
|
+
key=lambda p: (p.priority, p.provider_id),
|
|
40
|
+
)
|
|
41
|
+
out: list[str] = []
|
|
42
|
+
for p in ordered:
|
|
43
|
+
try:
|
|
44
|
+
text = p.collect(ctx)
|
|
45
|
+
except Exception: # noqa: BLE001 — providers never break the loop
|
|
46
|
+
continue
|
|
47
|
+
if text and text.strip():
|
|
48
|
+
out.append(text.strip())
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
def compose(self, ctx: InjectionContext, separator: str = "\n\n") -> str:
|
|
52
|
+
"""Convenience: collect() + join."""
|
|
53
|
+
return separator.join(self.collect(ctx))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
#: Global singleton (matches tool_registry pattern)
|
|
57
|
+
engine = InjectionEngine()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__all__ = ["InjectionEngine", "engine"]
|