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.
@@ -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"))
@@ -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