subactor-shell 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- subactor_shell/__init__.py +3 -0
- subactor_shell/__main__.py +4 -0
- subactor_shell/acp_agent.py +363 -0
- subactor_shell/app.py +482 -0
- subactor_shell/artifacts.py +198 -0
- subactor_shell/catalog.py +416 -0
- subactor_shell/chat.py +517 -0
- subactor_shell/compiler.py +191 -0
- subactor_shell/config.py +374 -0
- subactor_shell/connectors.py +503 -0
- subactor_shell/context_builder.py +153 -0
- subactor_shell/control.py +141 -0
- subactor_shell/control_env.py +93 -0
- subactor_shell/intent_ir.py +187 -0
- subactor_shell/models.py +78 -0
- subactor_shell/operations.py +287 -0
- subactor_shell/orchestration.py +324 -0
- subactor_shell/policy.py +38 -0
- subactor_shell/providers/__init__.py +63 -0
- subactor_shell/providers/anthropic.py +101 -0
- subactor_shell/providers/base.py +81 -0
- subactor_shell/providers/mock.py +32 -0
- subactor_shell/providers/openai_compat.py +303 -0
- subactor_shell/providers/subactor_control.py +191 -0
- subactor_shell/redaction.py +62 -0
- subactor_shell/repl.py +480 -0
- subactor_shell/routing.py +334 -0
- subactor_shell/secret_refs.py +82 -0
- subactor_shell/store.py +857 -0
- subactor_shell/terminal.py +79 -0
- subactor_shell/token_budget.py +46 -0
- subactor_shell/vault.py +170 -0
- subactor_shell-0.2.2.dist-info/METADATA +449 -0
- subactor_shell-0.2.2.dist-info/RECORD +38 -0
- subactor_shell-0.2.2.dist-info/WHEEL +5 -0
- subactor_shell-0.2.2.dist-info/entry_points.txt +2 -0
- subactor_shell-0.2.2.dist-info/licenses/LICENSE +13 -0
- subactor_shell-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .redaction import ExactRedactor
|
|
10
|
+
from .secret_refs import SecretResolver
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ControlError(RuntimeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SubactorControlClient:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
config: dict[str, Any],
|
|
21
|
+
resolver: SecretResolver,
|
|
22
|
+
*,
|
|
23
|
+
transport: httpx.BaseTransport | None = None,
|
|
24
|
+
):
|
|
25
|
+
self.config = config
|
|
26
|
+
self.resolver = resolver
|
|
27
|
+
self.transport = transport
|
|
28
|
+
self.base_url = str(config.get("base_url", "http://127.0.0.1:8088")).rstrip("/")
|
|
29
|
+
parsed = urlsplit(self.base_url)
|
|
30
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
31
|
+
raise ValueError("control.base_url musi być adresem http:// lub https://")
|
|
32
|
+
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
33
|
+
raise ValueError("control.base_url nie może zawierać credentiali ani query")
|
|
34
|
+
self.account_id = self._identifier(str(config.get("account_id", "softreck")))
|
|
35
|
+
self.provider = self._identifier(str(config.get("provider", "chatgpt")))
|
|
36
|
+
self.tool_id = self._identifier(str(config.get("tool_id", "codex")))
|
|
37
|
+
self.bearer_ref = str(config.get("bearer_ref", ""))
|
|
38
|
+
self.allowed_tools = sorted(
|
|
39
|
+
str(item) for item in config.get("allowed_tools", ["cli.status", "cli.plan", "cli.execute"])
|
|
40
|
+
)
|
|
41
|
+
self.timeout = float(config.get("timeout_seconds", 10.0))
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def _identifier(value: str) -> str:
|
|
45
|
+
import re
|
|
46
|
+
|
|
47
|
+
normalized = value.strip().lower()
|
|
48
|
+
if re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", normalized) is None:
|
|
49
|
+
raise ValueError(f"Nieprawidłowy identyfikator Subactor: {value}")
|
|
50
|
+
return normalized
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def endpoint(self) -> str:
|
|
54
|
+
return (
|
|
55
|
+
f"/mcp/accounts/{self.account_id}/providers/{self.provider}/tools/{self.tool_id}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def _token(self) -> str:
|
|
59
|
+
if not self.bearer_ref:
|
|
60
|
+
raise ControlError("Brak control.bearer_ref")
|
|
61
|
+
token = self.resolver.resolve(self.bearer_ref)
|
|
62
|
+
if len(token) < 16:
|
|
63
|
+
raise ControlError("Bearer Subactor jest pusty albo zbyt krótki")
|
|
64
|
+
return token
|
|
65
|
+
|
|
66
|
+
def health(self) -> tuple[bool, str]:
|
|
67
|
+
try:
|
|
68
|
+
with httpx.Client(
|
|
69
|
+
base_url=self.base_url, timeout=self.timeout, transport=self.transport
|
|
70
|
+
) as client:
|
|
71
|
+
response = client.get("/health")
|
|
72
|
+
except httpx.HTTPError as exc:
|
|
73
|
+
return False, f"błąd połączenia ({type(exc).__name__})"
|
|
74
|
+
if response.status_code != 200:
|
|
75
|
+
return False, f"HTTP {response.status_code}"
|
|
76
|
+
try:
|
|
77
|
+
payload = response.json()
|
|
78
|
+
except ValueError:
|
|
79
|
+
return False, "odpowiedź nie jest JSON"
|
|
80
|
+
if not isinstance(payload, dict):
|
|
81
|
+
return False, "odpowiedź JSON nie jest obiektem"
|
|
82
|
+
if payload.get("ok") is True:
|
|
83
|
+
return True, "ok=true"
|
|
84
|
+
status = payload.get("status")
|
|
85
|
+
return status == "ok", f"status={status!r}"
|
|
86
|
+
|
|
87
|
+
def _rpc(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
88
|
+
token = self._token()
|
|
89
|
+
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
|
90
|
+
payload: dict[str, Any] = {"jsonrpc": "2.0", "id": 1, "method": method}
|
|
91
|
+
if params is not None:
|
|
92
|
+
payload["params"] = params
|
|
93
|
+
try:
|
|
94
|
+
with httpx.Client(
|
|
95
|
+
base_url=self.base_url, timeout=self.timeout, transport=self.transport
|
|
96
|
+
) as client:
|
|
97
|
+
response = client.post(self.endpoint, headers=headers, json=payload)
|
|
98
|
+
except httpx.HTTPError as exc:
|
|
99
|
+
raise ControlError(f"Błąd połączenia z Subactor Control ({type(exc).__name__})") from exc
|
|
100
|
+
if response.status_code >= 400:
|
|
101
|
+
raise ControlError(f"Subactor Control zwrócił HTTP {response.status_code}")
|
|
102
|
+
try:
|
|
103
|
+
result = response.json()
|
|
104
|
+
except ValueError as exc:
|
|
105
|
+
raise ControlError("Subactor Control zwrócił nieprawidłowy JSON") from exc
|
|
106
|
+
if not isinstance(result, dict):
|
|
107
|
+
raise ControlError("Subactor Control zwrócił nieprawidłową odpowiedź RPC")
|
|
108
|
+
if "error" in result:
|
|
109
|
+
# Redagujemy bearer na wypadek odbicia nagłówków przez serwer/proxy.
|
|
110
|
+
safe = ExactRedactor([token]).redact(json.dumps(result["error"], ensure_ascii=False))
|
|
111
|
+
raise ControlError(f"Błąd JSON-RPC Subactor: {safe}")
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
def list_tools(self, *, strict: bool = True) -> list[dict[str, Any]]:
|
|
115
|
+
response = self._rpc("tools/list")
|
|
116
|
+
tools = response.get("result", {}).get("tools", [])
|
|
117
|
+
if not isinstance(tools, list):
|
|
118
|
+
raise ControlError("tools/list nie zwróciło listy")
|
|
119
|
+
names = sorted(
|
|
120
|
+
item.get("name") for item in tools if isinstance(item, dict) and isinstance(item.get("name"), str)
|
|
121
|
+
)
|
|
122
|
+
if strict and names != self.allowed_tools:
|
|
123
|
+
raise ControlError(
|
|
124
|
+
f"Naruszona granica MCP: otrzymano {names}, oczekiwano {self.allowed_tools}"
|
|
125
|
+
)
|
|
126
|
+
return [item for item in tools if isinstance(item, dict)]
|
|
127
|
+
|
|
128
|
+
def call_tool(
|
|
129
|
+
self,
|
|
130
|
+
name: str,
|
|
131
|
+
arguments: dict[str, Any],
|
|
132
|
+
*,
|
|
133
|
+
allow_execute: bool = False,
|
|
134
|
+
) -> Any:
|
|
135
|
+
self.list_tools(strict=True)
|
|
136
|
+
if name not in self.allowed_tools:
|
|
137
|
+
raise ControlError(f"Narzędzie '{name}' nie jest dozwolone")
|
|
138
|
+
if name == "cli.execute" and not allow_execute:
|
|
139
|
+
raise ControlError("cli.execute wymaga jawnego potwierdzenia")
|
|
140
|
+
response = self._rpc("tools/call", {"name": name, "arguments": arguments})
|
|
141
|
+
return response.get("result")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Read the bounded local Control environment without sourcing a shell file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import MutableMapping
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_ALLOWED_KEYS = frozenset(
|
|
13
|
+
{
|
|
14
|
+
"SUBACTOR_ADMIN_TOKEN",
|
|
15
|
+
"SUBACTOR_CONTROL_URL",
|
|
16
|
+
"SUBACTOR_FOUNDER_URL",
|
|
17
|
+
"SUBACTOR_PLANFILE_URL",
|
|
18
|
+
}
|
|
19
|
+
)
|
|
20
|
+
_MAX_ENV_BYTES = 1_048_576
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ControlEnvironmentError(ValueError):
|
|
24
|
+
"""The optional Control environment file cannot be safely used."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def default_environment_file() -> Path:
|
|
28
|
+
"""Locate ``platform/.env`` from a workspace, not from site-packages."""
|
|
29
|
+
|
|
30
|
+
roots: list[Path] = []
|
|
31
|
+
configured_root = os.environ.get("SUBACTOR_WORKSPACE_ROOT", "").strip()
|
|
32
|
+
if configured_root:
|
|
33
|
+
roots.append(Path(configured_root).expanduser())
|
|
34
|
+
for anchor in (Path.cwd(), Path(sys.argv[0]).resolve(), Path(__file__).resolve()):
|
|
35
|
+
roots.extend((anchor, *anchor.parents[:6]))
|
|
36
|
+
seen: set[Path] = set()
|
|
37
|
+
for root in roots:
|
|
38
|
+
candidate = root / "platform" / ".env"
|
|
39
|
+
if candidate in seen:
|
|
40
|
+
continue
|
|
41
|
+
seen.add(candidate)
|
|
42
|
+
if candidate.is_file():
|
|
43
|
+
return candidate
|
|
44
|
+
return Path.cwd() / "platform" / ".env"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _environment_file(values: MutableMapping[str, str]) -> tuple[Path, bool]:
|
|
48
|
+
configured = values.get("SUBACTOR_ENV_FILE", "").strip()
|
|
49
|
+
return (Path(configured).expanduser(), True) if configured else (default_environment_file(), False)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _read_selected_values(path: Path, *, explicit: bool) -> dict[str, str]:
|
|
53
|
+
if not path.exists():
|
|
54
|
+
if explicit:
|
|
55
|
+
raise ControlEnvironmentError("SUBACTOR_ENV_FILE nie istnieje")
|
|
56
|
+
return {}
|
|
57
|
+
if path.is_symlink() or not path.is_file():
|
|
58
|
+
raise ControlEnvironmentError("Plik Control environment musi być zwykłym plikiem")
|
|
59
|
+
try:
|
|
60
|
+
metadata = path.stat()
|
|
61
|
+
except OSError as exc:
|
|
62
|
+
raise ControlEnvironmentError("Nie można sprawdzić pliku Control environment") from exc
|
|
63
|
+
if metadata.st_size > _MAX_ENV_BYTES:
|
|
64
|
+
raise ControlEnvironmentError("Plik Control environment jest zbyt duży")
|
|
65
|
+
if stat.S_IMODE(metadata.st_mode) & 0o022:
|
|
66
|
+
raise ControlEnvironmentError("Plik Control environment nie może być zapisywalny dla grupy ani innych")
|
|
67
|
+
try:
|
|
68
|
+
content = path.read_text(encoding="utf-8")
|
|
69
|
+
except OSError as exc:
|
|
70
|
+
raise ControlEnvironmentError("Nie można odczytać pliku Control environment") from exc
|
|
71
|
+
selected: dict[str, str] = {}
|
|
72
|
+
for line in content.splitlines():
|
|
73
|
+
key, separator, value = line.partition("=")
|
|
74
|
+
if separator and key in _ALLOWED_KEYS and key not in selected:
|
|
75
|
+
selected[key] = value.strip().strip("\"'")
|
|
76
|
+
return selected
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def apply_control_environment(values: MutableMapping[str, str] | None = None) -> tuple[str, ...]:
|
|
80
|
+
"""Populate missing Control keys from one validated file; values are never logged."""
|
|
81
|
+
|
|
82
|
+
target = os.environ if values is None else values
|
|
83
|
+
path, explicit = _environment_file(target)
|
|
84
|
+
loaded = _read_selected_values(path, explicit=explicit)
|
|
85
|
+
applied: list[str] = []
|
|
86
|
+
for key, value in loaded.items():
|
|
87
|
+
if value and not target.get(key):
|
|
88
|
+
target[key] = value
|
|
89
|
+
applied.append(key)
|
|
90
|
+
if not target.get("SUBACTOR_CONTROL_URL") and target.get("SUBACTOR_FOUNDER_URL"):
|
|
91
|
+
target["SUBACTOR_CONTROL_URL"] = target["SUBACTOR_FOUNDER_URL"]
|
|
92
|
+
applied.append("SUBACTOR_CONTROL_URL")
|
|
93
|
+
return tuple(applied)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class IntentValidationError(ValueError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_ALLOWED_MODES = {"ask", "plan", "execute"}
|
|
13
|
+
_FORBIDDEN_ARGUMENT_NAMES = {
|
|
14
|
+
"cmd",
|
|
15
|
+
"command",
|
|
16
|
+
"shell",
|
|
17
|
+
"script",
|
|
18
|
+
"executable",
|
|
19
|
+
"endpoint",
|
|
20
|
+
"connector",
|
|
21
|
+
"connector_id",
|
|
22
|
+
"api_key",
|
|
23
|
+
"password",
|
|
24
|
+
"secret",
|
|
25
|
+
"token",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class IntentIR:
|
|
31
|
+
v: int
|
|
32
|
+
intent_id: str
|
|
33
|
+
mode: str = "execute"
|
|
34
|
+
args: dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
requirements: list[str] = field(default_factory=list)
|
|
36
|
+
constraints: list[str] = field(default_factory=list)
|
|
37
|
+
unresolved: list[str] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_dict(cls, payload: dict[str, Any]) -> "IntentIR":
|
|
41
|
+
if not isinstance(payload, dict):
|
|
42
|
+
raise IntentValidationError("IntentIR musi być obiektem JSON")
|
|
43
|
+
allowed_top = {
|
|
44
|
+
"v",
|
|
45
|
+
"intent_id",
|
|
46
|
+
"mode",
|
|
47
|
+
"args",
|
|
48
|
+
"requirements",
|
|
49
|
+
"constraints",
|
|
50
|
+
"unresolved",
|
|
51
|
+
}
|
|
52
|
+
unknown_top = set(payload) - allowed_top
|
|
53
|
+
if unknown_top:
|
|
54
|
+
raise IntentValidationError(
|
|
55
|
+
"IntentIR zawiera nieznane pola: " + ", ".join(sorted(unknown_top))
|
|
56
|
+
)
|
|
57
|
+
if payload.get("v", 1) != 1:
|
|
58
|
+
raise IntentValidationError("Obsługiwana jest wyłącznie wersja IntentIR v1")
|
|
59
|
+
intent_id = payload.get("intent_id")
|
|
60
|
+
if not isinstance(intent_id, str) or not intent_id.strip():
|
|
61
|
+
raise IntentValidationError("intent_id jest wymagany")
|
|
62
|
+
mode = payload.get("mode", "execute")
|
|
63
|
+
if mode not in _ALLOWED_MODES:
|
|
64
|
+
raise IntentValidationError("mode musi być ask, plan albo execute")
|
|
65
|
+
args = payload.get("args", {})
|
|
66
|
+
if not isinstance(args, dict):
|
|
67
|
+
raise IntentValidationError("args musi być obiektem")
|
|
68
|
+
clean_args: dict[str, Any] = {}
|
|
69
|
+
for raw_name, value in args.items():
|
|
70
|
+
name = str(raw_name)
|
|
71
|
+
if name.casefold() in _FORBIDDEN_ARGUMENT_NAMES:
|
|
72
|
+
raise IntentValidationError(
|
|
73
|
+
f"Argument '{name}' jest zarezerwowany; model nie może wybierać wykonawcy ani sekretu"
|
|
74
|
+
)
|
|
75
|
+
_validate_json_value(name, value)
|
|
76
|
+
clean_args[name] = value
|
|
77
|
+
|
|
78
|
+
def string_list(name: str) -> list[str]:
|
|
79
|
+
value = payload.get(name, [])
|
|
80
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
81
|
+
raise IntentValidationError(f"{name} musi być tablicą stringów")
|
|
82
|
+
return list(dict.fromkeys(item.strip() for item in value if item.strip()))
|
|
83
|
+
|
|
84
|
+
return cls(
|
|
85
|
+
v=1,
|
|
86
|
+
intent_id=intent_id.strip(),
|
|
87
|
+
mode=mode,
|
|
88
|
+
args=clean_args,
|
|
89
|
+
requirements=string_list("requirements"),
|
|
90
|
+
constraints=string_list("constraints"),
|
|
91
|
+
unresolved=string_list("unresolved"),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def to_dict(self) -> dict[str, Any]:
|
|
95
|
+
return {
|
|
96
|
+
"v": 1,
|
|
97
|
+
"intent_id": self.intent_id,
|
|
98
|
+
"mode": self.mode,
|
|
99
|
+
"args": self.args,
|
|
100
|
+
"requirements": self.requirements,
|
|
101
|
+
"constraints": self.constraints,
|
|
102
|
+
"unresolved": self.unresolved,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _validate_json_value(name: str, value: Any) -> None:
|
|
107
|
+
if isinstance(value, str):
|
|
108
|
+
lowered = value.casefold()
|
|
109
|
+
if "{{secret:" in lowered or lowered.startswith(("vault://", "env://", "file://")):
|
|
110
|
+
raise IntentValidationError(
|
|
111
|
+
f"Argument '{name}' nie może zawierać sekretu ani referencji do sekretu"
|
|
112
|
+
)
|
|
113
|
+
if len(value) > 4096:
|
|
114
|
+
raise IntentValidationError(f"Argument '{name}' jest zbyt długi")
|
|
115
|
+
return
|
|
116
|
+
if value is None or isinstance(value, (bool, int, float)):
|
|
117
|
+
return
|
|
118
|
+
if isinstance(value, list):
|
|
119
|
+
if len(value) > 64:
|
|
120
|
+
raise IntentValidationError(f"Argument '{name}' ma zbyt wiele elementów")
|
|
121
|
+
for item in value:
|
|
122
|
+
if isinstance(item, (dict, list)):
|
|
123
|
+
raise IntentValidationError("Zagnieżdżone struktury nie są dozwolone w IntentIR")
|
|
124
|
+
_validate_json_value(name, item)
|
|
125
|
+
return
|
|
126
|
+
raise IntentValidationError(f"Nieobsługiwany typ argumentu '{name}'")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def parse_json_object(text: str) -> dict[str, Any]:
|
|
130
|
+
candidate = text.strip()
|
|
131
|
+
if candidate.startswith("```"):
|
|
132
|
+
lines = candidate.splitlines()
|
|
133
|
+
if lines and lines[0].startswith("```"):
|
|
134
|
+
lines = lines[1:]
|
|
135
|
+
if lines and lines[-1].strip() == "```":
|
|
136
|
+
lines = lines[:-1]
|
|
137
|
+
candidate = "\n".join(lines).strip()
|
|
138
|
+
try:
|
|
139
|
+
parsed = json.loads(candidate)
|
|
140
|
+
except json.JSONDecodeError:
|
|
141
|
+
start = candidate.find("{")
|
|
142
|
+
end = candidate.rfind("}")
|
|
143
|
+
if start < 0 or end <= start:
|
|
144
|
+
raise IntentValidationError("Model nie zwrócił obiektu JSON")
|
|
145
|
+
try:
|
|
146
|
+
parsed = json.loads(candidate[start : end + 1])
|
|
147
|
+
except json.JSONDecodeError as exc:
|
|
148
|
+
raise IntentValidationError("Niepoprawny JSON zwrócony przez model") from exc
|
|
149
|
+
if not isinstance(parsed, dict):
|
|
150
|
+
raise IntentValidationError("Model musi zwrócić jeden obiekt JSON")
|
|
151
|
+
return parsed
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def intent_ir_schema(candidate_ids: list[str]) -> dict[str, Any]:
|
|
155
|
+
intent_property: dict[str, Any] = {"type": "string", "minLength": 1}
|
|
156
|
+
if candidate_ids:
|
|
157
|
+
intent_property["enum"] = candidate_ids
|
|
158
|
+
scalar: dict[str, Any] = {"type": ["string", "number", "integer", "boolean", "null"]}
|
|
159
|
+
return {
|
|
160
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
161
|
+
"title": "SubactorIntentIRV1",
|
|
162
|
+
"type": "object",
|
|
163
|
+
"additionalProperties": False,
|
|
164
|
+
"required": [
|
|
165
|
+
"v",
|
|
166
|
+
"intent_id",
|
|
167
|
+
"mode",
|
|
168
|
+
"args",
|
|
169
|
+
"requirements",
|
|
170
|
+
"constraints",
|
|
171
|
+
"unresolved",
|
|
172
|
+
],
|
|
173
|
+
"properties": {
|
|
174
|
+
"v": {"const": 1},
|
|
175
|
+
"intent_id": intent_property,
|
|
176
|
+
"mode": {"type": "string", "enum": ["ask", "plan", "execute"]},
|
|
177
|
+
"args": {
|
|
178
|
+
"type": "object",
|
|
179
|
+
"additionalProperties": {
|
|
180
|
+
"anyOf": [scalar, {"type": "array", "maxItems": 64, "items": scalar}]
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
"requirements": {"type": "array", "items": {"type": "string"}},
|
|
184
|
+
"constraints": {"type": "array", "items": {"type": "string"}},
|
|
185
|
+
"unresolved": {"type": "array", "items": {"type": "string"}},
|
|
186
|
+
},
|
|
187
|
+
}
|
subactor_shell/models.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
Role = Literal["system", "user", "assistant"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def utc_now() -> str:
|
|
12
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class Session:
|
|
17
|
+
id: str
|
|
18
|
+
name: str
|
|
19
|
+
provider: str
|
|
20
|
+
model: str
|
|
21
|
+
created_at: str
|
|
22
|
+
updated_at: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class Message:
|
|
27
|
+
id: int
|
|
28
|
+
session_id: str
|
|
29
|
+
role: Role
|
|
30
|
+
display_content: str
|
|
31
|
+
context_content: str
|
|
32
|
+
metadata: dict[str, Any]
|
|
33
|
+
created_at: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class Artifact:
|
|
38
|
+
id: str
|
|
39
|
+
original_path: str
|
|
40
|
+
stored_path: Path
|
|
41
|
+
mime_type: str
|
|
42
|
+
size: int
|
|
43
|
+
created_at: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(slots=True)
|
|
47
|
+
class ProviderProfile:
|
|
48
|
+
name: str
|
|
49
|
+
kind: str
|
|
50
|
+
model: str
|
|
51
|
+
base_url: str = ""
|
|
52
|
+
endpoint: str = ""
|
|
53
|
+
api_key_ref: str = ""
|
|
54
|
+
auth_required: bool = False
|
|
55
|
+
max_tokens: int = 4096
|
|
56
|
+
max_output_tokens: int = 256
|
|
57
|
+
structured_mode: str = "auto"
|
|
58
|
+
reasoning_effort: str = ""
|
|
59
|
+
anthropic_version: str = "2023-06-01"
|
|
60
|
+
timeout_seconds: float = 120.0
|
|
61
|
+
extra_headers: dict[str, str] = field(default_factory=dict)
|
|
62
|
+
input_cost_per_million: float = 0.0
|
|
63
|
+
cached_input_cost_per_million: float = 0.0
|
|
64
|
+
output_cost_per_million: float = 0.0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(slots=True)
|
|
68
|
+
class ChatChunk:
|
|
69
|
+
text: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(slots=True)
|
|
73
|
+
class PreparedPrompt:
|
|
74
|
+
display_content: str
|
|
75
|
+
safe_context_content: str
|
|
76
|
+
provider_content: str
|
|
77
|
+
resolved_secret_values: list[str]
|
|
78
|
+
metadata: dict[str, Any]
|