spatialagents 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.
- spatialagents/__init__.py +66 -0
- spatialagents/approvals.py +137 -0
- spatialagents/backend.py +181 -0
- spatialagents/cache_plan.py +117 -0
- spatialagents/chat/__init__.py +11 -0
- spatialagents/chat/cli.py +84 -0
- spatialagents/chat/http.py +308 -0
- spatialagents/chat/server.py +269 -0
- spatialagents/chat/static/chat.css +219 -0
- spatialagents/chat/static/chat.js +933 -0
- spatialagents/chat/static/index.html +16 -0
- spatialagents/conversations.py +292 -0
- spatialagents/errors.py +73 -0
- spatialagents/gateway.py +192 -0
- spatialagents/offload.py +92 -0
- spatialagents/orchestrator.py +259 -0
- spatialagents/plans.py +800 -0
- spatialagents/policy.py +118 -0
- spatialagents/prefix.py +79 -0
- spatialagents/py.typed +0 -0
- spatialagents/runner.py +813 -0
- spatialagents/schema.py +167 -0
- spatialagents/serialize.py +88 -0
- spatialagents/sessions.py +276 -0
- spatialagents/testing.py +927 -0
- spatialagents/toolcache.py +144 -0
- spatialagents/tools.py +460 -0
- spatialagents/trace.py +237 -0
- spatialagents/wire.py +194 -0
- spatialagents-0.1.0.dist-info/METADATA +55 -0
- spatialagents-0.1.0.dist-info/RECORD +33 -0
- spatialagents-0.1.0.dist-info/WHEEL +4 -0
- spatialagents-0.1.0.dist-info/entry_points.txt +2 -0
spatialagents/policy.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""What an agent is allowed to do, decided before anything runs.
|
|
2
|
+
|
|
3
|
+
Four permission classes, from "just look" to "cannot be undone", and five
|
|
4
|
+
egress classes for what leaves the process. The defaults are the ones a user
|
|
5
|
+
who started a conversation and said nothing else would expect: it may read,
|
|
6
|
+
look at rendered pictures and change the product's own reversible state, but
|
|
7
|
+
it may not write into the user's source files or delete anything unless the
|
|
8
|
+
policy allows it AND the call is confirmed -- in a conversation, by the
|
|
9
|
+
person's approval (`approvals.py`), which no agent can give itself.
|
|
10
|
+
|
|
11
|
+
The vocabulary is shared by every product; what each tool's class is, and how
|
|
12
|
+
a call is checked against a policy, is the product's (its `ToolBackend`).
|
|
13
|
+
Invariant: raw scientific data does not go to a model by default -- neither
|
|
14
|
+
`row_level` nor `raw_pixels` is in `DEFAULT_EGRESS`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import dataclasses
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
PERMISSIONS = ("read", "reversible_write", "source_file_write", "destructive")
|
|
23
|
+
|
|
24
|
+
#: The permission classes a conversation pauses on for the person's approval.
|
|
25
|
+
NEEDS_APPROVAL = ("source_file_write", "destructive")
|
|
26
|
+
|
|
27
|
+
#: What a result carries out of the product, least to most.
|
|
28
|
+
EGRESS = ("metadata", "aggregates", "row_level", "rendered_pixels", "raw_pixels")
|
|
29
|
+
|
|
30
|
+
DEFAULT_EGRESS = frozenset({"metadata", "aggregates", "rendered_pixels"})
|
|
31
|
+
|
|
32
|
+
#: What a scope check answers, most capable first. Every product's classifier
|
|
33
|
+
#: answers one of these four; all four are good answers.
|
|
34
|
+
SCOPE_STATES = ("can_execute", "can_analyze", "can_recommend", "outside_domain")
|
|
35
|
+
CAN_EXECUTE, CAN_ANALYZE, CAN_RECOMMEND, OUTSIDE_DOMAIN = SCOPE_STATES
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Policy:
|
|
40
|
+
allow_source_writes: bool = False
|
|
41
|
+
allow_destructive: bool = False
|
|
42
|
+
egress: frozenset = field(default_factory=lambda: DEFAULT_EGRESS)
|
|
43
|
+
#: False: reads only -- a `read`-scoped token's policy.
|
|
44
|
+
allow_writes: bool = True
|
|
45
|
+
#: Who is calling, when the transport knows (a token's id); in audit lines.
|
|
46
|
+
principal: str | None = None
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_flags(cls, *, allow_source_writes=False, allow_destructive=False,
|
|
50
|
+
egress=None) -> "Policy":
|
|
51
|
+
allowed = set(DEFAULT_EGRESS)
|
|
52
|
+
for item in egress or ():
|
|
53
|
+
item = item.strip()
|
|
54
|
+
if not item:
|
|
55
|
+
continue
|
|
56
|
+
if item not in EGRESS:
|
|
57
|
+
raise ValueError(f"unknown egress class {item!r}; expected any of {EGRESS}")
|
|
58
|
+
allowed.add(item)
|
|
59
|
+
return cls(bool(allow_source_writes), bool(allow_destructive), frozenset(allowed))
|
|
60
|
+
|
|
61
|
+
def describe(self) -> dict:
|
|
62
|
+
return {"allow_source_writes": self.allow_source_writes,
|
|
63
|
+
"allow_destructive": self.allow_destructive,
|
|
64
|
+
"allow_writes": self.allow_writes,
|
|
65
|
+
"egress": sorted(self.egress, key=EGRESS.index),
|
|
66
|
+
"principal": self.principal}
|
|
67
|
+
|
|
68
|
+
def narrowed_by_scope(self, scope: str, principal: str | None = None) -> "Policy":
|
|
69
|
+
"""This policy as a token of `scope` may use it: narrower, never wider.
|
|
70
|
+
|
|
71
|
+
`read` allows no writes at all; `write` (and `bridge`, the same policy
|
|
72
|
+
for another application) allows the product's own reversible state but
|
|
73
|
+
no source-file writes or deletes; `admin` leaves the server's policy as
|
|
74
|
+
it is. An unknown scope is treated as `read`.
|
|
75
|
+
"""
|
|
76
|
+
if scope == "admin":
|
|
77
|
+
return dataclasses.replace(self, principal=principal)
|
|
78
|
+
if scope in ("write", "bridge"):
|
|
79
|
+
# `bridge`: another application's token. It writes the product's
|
|
80
|
+
# own reversible state like `write`, never the user's source files
|
|
81
|
+
# -- a peer may hold the same file open, and a source write under
|
|
82
|
+
# it is the one thing it must never have done.
|
|
83
|
+
return dataclasses.replace(self, allow_source_writes=False,
|
|
84
|
+
allow_destructive=False, principal=principal)
|
|
85
|
+
return dataclasses.replace(self, allow_writes=False, allow_source_writes=False,
|
|
86
|
+
allow_destructive=False, principal=principal)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def permitted(capability, policy: Policy) -> bool:
|
|
90
|
+
"""Whether a call COULD run under this policy, given confirmation.
|
|
91
|
+
`capability` is anything with an `egress` and a `permission`."""
|
|
92
|
+
if capability.egress not in policy.egress:
|
|
93
|
+
return False
|
|
94
|
+
if capability.permission != "read" and not policy.allow_writes:
|
|
95
|
+
return False
|
|
96
|
+
if capability.permission == "source_file_write":
|
|
97
|
+
return policy.allow_source_writes
|
|
98
|
+
if capability.permission == "destructive":
|
|
99
|
+
return policy.allow_destructive
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def elevated(policy: Policy, permission: str) -> Policy:
|
|
104
|
+
"""`policy` with the one flag an approved call of `permission` needs."""
|
|
105
|
+
if permission == "source_file_write":
|
|
106
|
+
return dataclasses.replace(policy, allow_source_writes=True)
|
|
107
|
+
if permission == "destructive":
|
|
108
|
+
return dataclasses.replace(policy, allow_destructive=True)
|
|
109
|
+
return policy
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def policy_from(described: dict | None) -> Policy:
|
|
113
|
+
"""The policy a record's `Policy.describe()` wrote down."""
|
|
114
|
+
d = described or {}
|
|
115
|
+
return Policy(allow_source_writes=bool(d.get("allow_source_writes")),
|
|
116
|
+
allow_destructive=bool(d.get("allow_destructive")),
|
|
117
|
+
egress=frozenset(d.get("egress") or DEFAULT_EGRESS),
|
|
118
|
+
allow_writes=d.get("allow_writes", True) is not False, principal=d.get("principal"))
|
spatialagents/prefix.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""The cached prefix a conversation starts from: the mechanism, not the words.
|
|
2
|
+
|
|
3
|
+
A prompt cache pays only while the prefix is byte-identical, so the system
|
|
4
|
+
prompt of a conversation is built once, from parts that are the same for
|
|
5
|
+
every conversation of one build, policy and plugin set, and frozen in the
|
|
6
|
+
conversation's record:
|
|
7
|
+
|
|
8
|
+
[identity + house rules] [SKILL <name>: one short skill] [tool catalog, cache breakpoint]
|
|
9
|
+
|
|
10
|
+
Nothing volatile may enter it -- no conversation, dataset, project or account
|
|
11
|
+
id, no time, no path (a product that interpolates its session root into its
|
|
12
|
+
rules breaks the cache for everyone). What each part says is the product's:
|
|
13
|
+
Plexora's identity and house rules, SCIMAP Pro's `RULES` block and its
|
|
14
|
+
`dataset-triage` skill; each product pins its own prefix's fingerprint in a
|
|
15
|
+
test. The tool catalog is `tools.catalog_text`.
|
|
16
|
+
|
|
17
|
+
A sub-agent starts from its parent's exact prefix (the fork rule: same tools,
|
|
18
|
+
same system, so it reads the parent's cache) and gets its brief as its first
|
|
19
|
+
user turn (`subagent_brief`).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from spatialagents.wire import canonical, text_block
|
|
25
|
+
|
|
26
|
+
#: The identity of a conversation whose product gave none: neutral, for tests and tools.
|
|
27
|
+
DEFAULT_IDENTITY = (
|
|
28
|
+
"You are the assistant inside a scientific application. You answer the user's questions about "
|
|
29
|
+
"their data by calling the application's tools, and you change things only when the user asked "
|
|
30
|
+
"for the change."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
DEFAULT_RULES = (
|
|
34
|
+
"HOUSE RULES\n"
|
|
35
|
+
"1. Start from the user's question, not from a tool name.\n"
|
|
36
|
+
"2. The tool catalog below lists every tool you may use by name and purpose. To use one, call "
|
|
37
|
+
"load_tool with its name (several at once if you will need several): it returns each tool's full "
|
|
38
|
+
"definition. Then run the tool with call_tool, giving its name and arguments that match that "
|
|
39
|
+
"definition. Load only what you need.\n"
|
|
40
|
+
"3. Claim only what a tool returned. Say which sentences are a result, which are your reading of it "
|
|
41
|
+
"and which are background knowledge.\n"
|
|
42
|
+
"4. A tool that writes into the user's source files or cannot be undone waits for the user's "
|
|
43
|
+
"approval; if the user declines, do not try another way round.\n"
|
|
44
|
+
"5. A large result arrives as a stub with an artifact_id, a head and a tail; read_artifact returns "
|
|
45
|
+
"the slices you need.\n"
|
|
46
|
+
"6. Be brief. The output is AI-generated and the user verifies it before relying on it."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_prefix(identity: str, rules: str, skill_text: str, catalog_text: str, *,
|
|
51
|
+
skill: str = "dataset-triage") -> list:
|
|
52
|
+
"""The system prompt of a conversation: identity and house rules, one short
|
|
53
|
+
skill, then the frozen tool catalog, with the only breakpoint last.
|
|
54
|
+
Byte-stable for one build, policy and plugin set; nothing per-conversation."""
|
|
55
|
+
return [text_block(identity + "\n\n" + rules),
|
|
56
|
+
text_block(f"SKILL {skill}\n" + (skill_text or "")),
|
|
57
|
+
text_block(catalog_text, cache=True)]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def default_prefix(catalog_text: str) -> list:
|
|
61
|
+
"""`build_prefix` with the neutral identity and rules, and no skill text."""
|
|
62
|
+
return build_prefix(DEFAULT_IDENTITY, DEFAULT_RULES, "", catalog_text)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def subagent_brief(role: str, brief: str, tools: list, inputs: dict | None = None, *,
|
|
66
|
+
parent: str = "the main conversation") -> str:
|
|
67
|
+
"""The first user turn of a sub-agent, after the parent's exact prefix (the
|
|
68
|
+
fork rule: same tools, same system, so the sub-agent reads the parent's
|
|
69
|
+
cache). `parent` is how the product names the conversation it works for
|
|
70
|
+
("the main Plexora AI conversation")."""
|
|
71
|
+
lines = [f"You are a sub-agent ({role}) working for {parent}. Do only this:",
|
|
72
|
+
brief.strip(),
|
|
73
|
+
"Tools you may call: " + (", ".join(tools) if tools else "the read-only tools") + ".",
|
|
74
|
+
"When you are done, reply with ONE JSON object and nothing else: "
|
|
75
|
+
'{"summary": "<two or three sentences>", "findings": ["..."], "operation_ids": ["..."], '
|
|
76
|
+
'"artifact_ids": ["..."]}.']
|
|
77
|
+
if inputs:
|
|
78
|
+
lines.insert(2, "Inputs: " + canonical(inputs))
|
|
79
|
+
return "\n".join(lines)
|
spatialagents/py.typed
ADDED
|
File without changes
|