tenaxisos-sdk 0.2.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,14 @@
1
+ """TenaxisOS SDK — build, validate, sign, and publish packages."""
2
+
3
+ __version__ = "0.2.0"
4
+
5
+ # The SDK authoring/runtime host contract version (`compatibility.sdk_api` in
6
+ # a package manifest). Distinct axis from `__version__`: this only changes
7
+ # when the *shape* of `tenaxisos_sdk.agents`/`tenaxisos_sdk.host` changes in
8
+ # a way that could break a package built against an older contract (a new
9
+ # required `Host` method, a renamed `Agent` method, a changed `ToolOutcome`
10
+ # member) — not on every release. Bump both together only when a release
11
+ # happens to also be a contract change. The kernel checks a package's
12
+ # declared `compatibility.sdk_api` range against this value before running
13
+ # packages that rely on the public SDK runtime contract.
14
+ SDK_API_VERSION = "0.2.0"
@@ -0,0 +1,20 @@
1
+ from tenaxisos_sdk.agents.base import Agent, ProgressCallback
2
+ from tenaxisos_sdk.agents.context import RunContext
3
+ from tenaxisos_sdk.agents.findings import (
4
+ BASE_INPUT_SCHEMA,
5
+ FINDING_SCHEMA,
6
+ Finding,
7
+ validate_findings,
8
+ )
9
+ from tenaxisos_sdk.agents.manifest import generate_agent_payload
10
+
11
+ __all__ = [
12
+ "Agent",
13
+ "ProgressCallback",
14
+ "RunContext",
15
+ "Finding",
16
+ "FINDING_SCHEMA",
17
+ "BASE_INPUT_SCHEMA",
18
+ "validate_findings",
19
+ "generate_agent_payload",
20
+ ]
@@ -0,0 +1,277 @@
1
+ """Agent — the SDK's public agent-authoring base class.
2
+
3
+ Provides the shape a native agent needs (metadata class attributes,
4
+ `add_finding`/`set_output`/`effective_config`/`progress`/`fail`, an
5
+ abstract `run()`) while keeping every LLM/tool/file call routed through a
6
+ `Host` (`tenaxisos_sdk.host`) supplied at construction time, never through
7
+ a direct kernel import. In production the kernel supplies a `Host`
8
+ implementation that binds this same class to real governance; for local
9
+ development, `tenaxisos_sdk.testing.local_host.LocalMockHost` is a
10
+ deterministic fake, so an author can write and run tests with no kernel
11
+ installed.
12
+
13
+ What this deliberately does NOT include, since these are the kernel's job,
14
+ not something an agent author should reach for directly:
15
+ - No org/tenant injection, no direct tool registry access — the host
16
+ decides how a tool call actually executes.
17
+ - No LLM provider/task-hint routing — the host's `llm_complete` already
18
+ returns a routed, governed completion.
19
+ - No integration wire-name resolution — an agent calls `self.tools.call(name,
20
+ args)` by logical name; the host resolves the underlying wiring.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from abc import ABC, abstractmethod
25
+ from typing import Any, Awaitable, Callable, Optional
26
+
27
+ from tenaxisos_sdk.agents.context import RunContext
28
+ from tenaxisos_sdk.agents.findings import BASE_INPUT_SCHEMA, Finding
29
+ from tenaxisos_sdk.host import Host, ToolCallResult
30
+
31
+ ProgressCallback = Callable[[int, str], Awaitable[None]]
32
+
33
+
34
+ class Agent(ABC):
35
+ """Base class for all SDK-authored agents.
36
+
37
+ Each agent implements `run()` and returns a list of `Finding` dicts via
38
+ `self.findings` / `self.add_finding(...)`.
39
+ """
40
+
41
+ # -- Metadata (override in subclass) ----------------------------------
42
+ display_name: str = "Unknown Agent"
43
+ description: str = ""
44
+ category: str = "general"
45
+ tools: list[str] = []
46
+ capability_tags: list[str] = []
47
+ toolchains: list[str] = []
48
+ input_types: list[str] = []
49
+ output_types: list[str] = []
50
+ CONFIG_SCHEMA: dict = {}
51
+ INPUT_SCHEMA: dict = {}
52
+ OUTPUT_SCHEMA: dict = {}
53
+ DECLARED_OUTPUT_SHAPE: dict = {}
54
+ privacy_tier: str = "standard"
55
+ # -- Behaviour contract ------------------------------------------------
56
+ version: str = "1.0.0"
57
+ deterministic: bool = True
58
+ idempotent: bool = True
59
+ side_effects: str = "none" # none | external | irreversible
60
+ _skip_schema_validation: bool = False
61
+
62
+ def __init__(self, config: dict | None = None, *, host: Host) -> None:
63
+ """`host` is required and keyword-only: an `Agent` never has an
64
+ implicit default authority channel. Callers must be explicit about
65
+ whether they're binding to `LocalMockHost` or a production host."""
66
+ self.config: dict[str, Any] = config or {}
67
+ self._host = host
68
+ self.findings: list[Finding] = []
69
+ self.output: dict[str, Any] = {}
70
+ self.log_lines: list[str] = []
71
+ self._run_failed = False
72
+ self._fail_reason = ""
73
+ self.ctx = RunContext(self.config.get("context", {}))
74
+ self._validate_config_schema()
75
+
76
+ # -- Contract validation -------------------------------------------------
77
+
78
+ def workflow_variables(self) -> dict[str, Any]:
79
+ """Flat workflow/run variable values resolved for this run."""
80
+ workflow = self.config.get("workflow", {})
81
+ if isinstance(workflow, dict):
82
+ variables = workflow.get("effective_variables", {})
83
+ if isinstance(variables, dict):
84
+ return variables
85
+ return {}
86
+
87
+ def workflow_var(self, name: str, default: Any = None) -> Any:
88
+ """Read a resolved workflow variable by name."""
89
+ return self.workflow_variables().get(name, default)
90
+
91
+ def effective_config(self, name: str | None = None, default: Any = None) -> Any:
92
+ """Read the node's effective runtime config.
93
+
94
+ Effective config is derived by the host using this priority:
95
+ agent defaults < workflow variables < run variables < node config.
96
+ """
97
+ effective = self.config.get("effective_config")
98
+ if not isinstance(effective, dict):
99
+ effective = self.config
100
+ if name is None:
101
+ return effective
102
+ return effective.get(name, default)
103
+
104
+ def _validate_config_schema(self) -> None:
105
+ if self.__class__._skip_schema_validation:
106
+ return
107
+ schema = self.__class__.CONFIG_SCHEMA or {}
108
+ missing = [k for k, v in schema.items() if v.get("required") and k not in self.config]
109
+ if missing:
110
+ raise ValueError(
111
+ f"{self.__class__.__name__}: missing required config fields: {', '.join(missing)}"
112
+ )
113
+
114
+ def validate_input(self, runtime_data: dict) -> list[str]:
115
+ """Validate runtime execution data against INPUT_SCHEMA. Returns a
116
+ list of error strings — empty means valid."""
117
+ errors: list[str] = []
118
+ schema = {**BASE_INPUT_SCHEMA, **self.__class__.INPUT_SCHEMA}
119
+ for field_name, spec in schema.items():
120
+ if spec.get("required") and field_name not in runtime_data:
121
+ errors.append(f"missing required input field: '{field_name}'")
122
+ if field_name in runtime_data:
123
+ allowed = spec.get("enum")
124
+ val = runtime_data[field_name]
125
+ if allowed is not None and val not in allowed:
126
+ errors.append(
127
+ f"input field '{field_name}' must be one of {allowed}, got {val!r}"
128
+ )
129
+ return errors
130
+
131
+ def validate_output(self, findings: list) -> list[str]:
132
+ """Validate findings against `FINDING_SCHEMA` before they're stored.
133
+ Returns a list of warning strings — empty means valid. Non-fatal:
134
+ callers should append error findings rather than aborting the run."""
135
+ from tenaxisos_sdk.agents.findings import validate_findings
136
+
137
+ return validate_findings(findings)
138
+
139
+ # -- Host-brokered channels ----------------------------------------------
140
+
141
+ async def call_tool(
142
+ self, name: str, args: Optional[dict[str, Any]] = None, **kwargs: Any
143
+ ) -> ToolCallResult:
144
+ """Propose a tool call through the host. The host decides
145
+ allow/deny/approval/dry-run — this never executes anything itself."""
146
+ payload: dict[str, Any] = dict(args or {})
147
+ payload.update(kwargs)
148
+ result = await self._host.call_tool(name, payload)
149
+ self.log_lines.append(f"[tool] {name}: {result.outcome.value}")
150
+ return result
151
+
152
+ async def llm_complete(
153
+ self, prompt: str, system: str | None = None, category: str = "llm"
154
+ ) -> str | None:
155
+ """Complete a prompt through the host's governed LLM channel.
156
+
157
+ Mirrors `BaseAgent.llm_complete`: emits an 'error' severity finding
158
+ on failure instead of raising, and returns None so callers can check
159
+ the result without a try/except.
160
+ """
161
+ try:
162
+ return await self._host.llm_complete(prompt, system)
163
+ except Exception as exc: # noqa: BLE001 - host failures are reported as findings, not raised
164
+ self.add_finding(
165
+ severity="error",
166
+ category=category,
167
+ title="LLM call failed",
168
+ message=str(exc),
169
+ )
170
+ return None
171
+
172
+ async def read_file(self, path: str) -> str:
173
+ return await self._host.read_file(path)
174
+
175
+ async def read_file_bytes(self, path: str) -> bytes:
176
+ return await self._host.read_file_bytes(path)
177
+
178
+ async def list_files(self, path: str, pattern: str = "*") -> list[str]:
179
+ return await self._host.list_files(path, pattern)
180
+
181
+ # -- Run lifecycle --------------------------------------------------------
182
+
183
+ @abstractmethod
184
+ async def run(self, on_progress: ProgressCallback | None = None) -> list[dict]:
185
+ """Execute the agent. Should call `self.progress(...)` periodically.
186
+ Returns findings as dicts (use `self.findings_as_dicts()`)."""
187
+
188
+ async def progress(self, on_progress: Optional[ProgressCallback], pct: int, msg: str) -> None:
189
+ self.log_lines.append(f"[{pct}%] {msg}")
190
+ if on_progress:
191
+ await on_progress(pct, msg)
192
+
193
+ def fail(self, reason: str) -> None:
194
+ """Mark this run as failed without raising — the caller records
195
+ status='error' even though `run()` returned normally."""
196
+ self._run_failed = True
197
+ self._fail_reason = reason
198
+
199
+ @property
200
+ def failed(self) -> bool:
201
+ return self._run_failed
202
+
203
+ @property
204
+ def fail_reason(self) -> str:
205
+ return self._fail_reason
206
+
207
+ # -- Findings / outputs ----------------------------------------------------
208
+
209
+ def add_finding(
210
+ self,
211
+ severity: str,
212
+ category: str,
213
+ title: str,
214
+ message: str,
215
+ file: str | None = None,
216
+ line: int | None = None,
217
+ rule: str | None = None,
218
+ suggestion: str | None = None,
219
+ **extra: Any,
220
+ ) -> Finding:
221
+ f = Finding(severity, category, title, message, file, line, rule, suggestion)
222
+ f.extra.update({k: v for k, v in extra.items() if v is not None})
223
+ self.findings.append(f)
224
+ return f
225
+
226
+ def findings_as_dicts(self) -> list[dict]:
227
+ return [f.to_dict() for f in self.findings]
228
+
229
+ def set_output(self, name: str, value: Any) -> None:
230
+ """Record a declared contract output for this run. The key must be
231
+ declared in `output_types`."""
232
+ if name == "findings" or name not in self.__class__.output_types:
233
+ raise ValueError(
234
+ f"{self.__class__.__name__}: output '{name}' is not declared in output_types"
235
+ )
236
+ self.output[name] = value
237
+
238
+ def output_as_dict(self) -> dict[str, Any]:
239
+ return dict(self.output)
240
+
241
+ def summary(self) -> dict:
242
+ counts: dict[str, int] = {}
243
+ for f in self.findings:
244
+ counts[f.severity] = counts.get(f.severity, 0) + 1
245
+ return {"total": len(self.findings), "by_severity": counts}
246
+
247
+ @classmethod
248
+ def docs_metadata(cls) -> dict:
249
+ """Structured documentation metadata. See `agents/manifest.py` for
250
+ full package-manifest generation."""
251
+ config_docs = {}
252
+ for k, v in (cls.CONFIG_SCHEMA or {}).items():
253
+ config_docs[k] = {
254
+ "type": v.get("type", "str"),
255
+ "required": v.get("required", False),
256
+ "description": v.get("desc", v.get("description", "")),
257
+ "default": v.get("default"),
258
+ }
259
+ return {
260
+ "name": cls.display_name,
261
+ "category": cls.category,
262
+ "description": cls.description,
263
+ "input_types": list(cls.input_types),
264
+ "output_types": list(cls.output_types),
265
+ "config": config_docs,
266
+ "tools": list(cls.tools),
267
+ "capability_tags": list(cls.capability_tags),
268
+ "toolchains": list(cls.toolchains),
269
+ "privacy_tier": cls.privacy_tier,
270
+ "version": cls.version,
271
+ "deterministic": cls.deterministic,
272
+ "idempotent": cls.idempotent,
273
+ "side_effects": cls.side_effects,
274
+ "input_schema": dict(cls.INPUT_SCHEMA),
275
+ "output_schema": dict(cls.OUTPUT_SCHEMA),
276
+ "declared_output_shape": dict(cls.DECLARED_OUTPUT_SHAPE),
277
+ }
@@ -0,0 +1,132 @@
1
+ """RunContext — structured read-only accessor over kernel-assembled context.
2
+
3
+ A read-only view over per-run context, so agent authors don't need to dig
4
+ through raw JSON. The kernel assembles the payload in production; the local
5
+ mock host (`tenaxisos_sdk.testing.local_host`) hands this the same shape
6
+ locally so agent code doesn't need to know which one it's running under.
7
+
8
+ Usage inside an SDK-authored agent:
9
+ prior = self.ctx.prior_findings("SomeUpstreamAgent")
10
+ rules = self.ctx.compliance_rules()
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+
17
+ class RunContext:
18
+ """Structured accessor for the runtime context payload assembled by the
19
+ host. Agents receive this as `self.ctx` — see `agents/base.py`."""
20
+
21
+ def __init__(self, payload: dict[str, Any] | None = None) -> None:
22
+ self._payload = payload or {}
23
+
24
+ # -- Identity --------------------------------------------------------
25
+
26
+ @property
27
+ def agent_name(self) -> str:
28
+ return self._payload.get("agent_name", "unknown")
29
+
30
+ @property
31
+ def assembled_at(self) -> str:
32
+ return self._payload.get("assembled_at", "")
33
+
34
+ @property
35
+ def sources(self) -> list[str]:
36
+ return self._payload.get("sources", [])
37
+
38
+ @property
39
+ def snapshot_id(self) -> str | None:
40
+ return self._payload.get("context_snapshot_id")
41
+
42
+ # -- Prior findings from other agents ---------------------------------
43
+
44
+ def prior_findings(self, agent_name: str | None = None) -> list[dict]:
45
+ """Findings from the most recent completed run of other agents.
46
+ Pass agent_name to filter to a specific agent."""
47
+ all_prior = self._payload.get("prior_findings", [])
48
+ if agent_name:
49
+ for entry in all_prior:
50
+ if entry.get("agent") == agent_name:
51
+ return entry.get("findings", [])
52
+ return []
53
+ flat: list[dict] = []
54
+ for entry in all_prior:
55
+ flat.extend(entry.get("findings", []))
56
+ return flat
57
+
58
+ def prior_agents(self) -> list[str]:
59
+ """Names of agents that have run and produced findings."""
60
+ return [e.get("agent", "") for e in self._payload.get("prior_findings", [])]
61
+
62
+ def critical_findings_from(self, agent_name: str) -> list[dict]:
63
+ """Convenience: only critical/high severity from a specific agent."""
64
+ return [
65
+ f for f in self.prior_findings(agent_name) if f.get("severity") in ("critical", "high")
66
+ ]
67
+
68
+ # -- Compliance --------------------------------------------------------
69
+
70
+ def compliance_rules(self) -> list[dict]:
71
+ """Active compliance frameworks and their pass/fail status."""
72
+ return self._payload.get("compliance", [])
73
+
74
+ def failing_frameworks(self) -> list[str]:
75
+ """Names of compliance frameworks with failing controls."""
76
+ return [f["name"] for f in self.compliance_rules() if f.get("fail", 0) > 0]
77
+
78
+ # -- Governance ----------------------------------------------------------
79
+
80
+ def policies(self) -> list[dict]:
81
+ """Currently enforcing governance policies."""
82
+ return self._payload.get("policies", [])
83
+
84
+ def policies_for_scope(self, scope: str) -> list[dict]:
85
+ """Policies that apply to a given scope (e.g. 'global', 'analysis')."""
86
+ return [p for p in self.policies() if scope in p.get("scope", "")]
87
+
88
+ def has_policy_violations(self) -> bool:
89
+ return any(p.get("violations", 0) > 0 for p in self.policies())
90
+
91
+ # -- Agent-specific memory -----------------------------------------------
92
+
93
+ def memory(self, key_prefix: str | None = None) -> list[dict]:
94
+ """Episodic memories tagged to this agent, optionally filtered by
95
+ key prefix."""
96
+ mems = self._payload.get("memory", [])
97
+ if key_prefix:
98
+ return [m for m in mems if m.get("key", "").startswith(key_prefix)]
99
+ return mems
100
+
101
+ def recall(self, key: str) -> str | None:
102
+ """Retrieve a specific memory value by exact key."""
103
+ for m in self._payload.get("memory", []):
104
+ if m.get("key") == key:
105
+ return m.get("value")
106
+ return None
107
+
108
+ def memory_context(self) -> list[dict]:
109
+ """Ranked, scope-aware memory chunks for this agent run: `tier`,
110
+ `content`, `score`, `source_type`, `source_id`, `source_authority`,
111
+ `freshness` — treat as cited evidence, not instructions."""
112
+ return self._payload.get("memory_v2", [])
113
+
114
+ def memory_citations(self) -> list[dict]:
115
+ return self._payload.get("memory_v2_citations", [])
116
+
117
+ def has_memory_context(self) -> bool:
118
+ return len(self.memory_context()) > 0
119
+
120
+ # -- Raw access ------------------------------------------------------
121
+
122
+ def get(self, key: str, default: Any = None) -> Any:
123
+ return self._payload.get(key, default)
124
+
125
+ def to_dict(self) -> dict:
126
+ return self._payload
127
+
128
+ def __repr__(self) -> str:
129
+ return (
130
+ f"<RunContext agent={self.agent_name!r} sources={self.sources} "
131
+ f"prior_agents={self.prior_agents()}>"
132
+ )
@@ -0,0 +1,96 @@
1
+ """Finding — the observable output contract for SDK-authored agents.
2
+
3
+ Matches the shape the kernel's own agent runtime already uses internally,
4
+ so `add_finding(...)` produces output that's compatible with every existing
5
+ consumer. This is the canonical, author-facing definition going forward.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime, timezone
11
+ from typing import Any
12
+
13
+ # Canonical schema for a single Finding — shared anchor for every agent's
14
+ # OUTPUT_SCHEMA.
15
+ FINDING_SCHEMA: dict[str, dict] = {
16
+ "severity": {"type": "str", "required": True,
17
+ "enum": ["critical", "high", "medium", "low", "info", "error"]},
18
+ "category": {"type": "str", "required": True},
19
+ "title": {"type": "str", "required": True},
20
+ "message": {"type": "str", "required": True},
21
+ "confidence": {"type": "float", "required": False, "min": 0.0, "max": 1.0},
22
+ }
23
+
24
+ # Runtime inputs every agent receives from the workflow engine. Agents extend
25
+ # this in their own INPUT_SCHEMA if they need additional fields.
26
+ BASE_INPUT_SCHEMA: dict[str, dict] = {
27
+ "upstream_findings": {
28
+ "type": "array", "required": False,
29
+ "desc": "Findings forwarded from upstream workflow agents",
30
+ },
31
+ "classification": {
32
+ "type": "str", "required": False,
33
+ "desc": "Data classification label of the input payload",
34
+ "enum": ["public", "internal", "confidential", "secret"],
35
+ },
36
+ }
37
+
38
+
39
+ @dataclass
40
+ class Finding:
41
+ """A single finding from an agent run."""
42
+
43
+ severity: str
44
+ category: str
45
+ title: str
46
+ message: str
47
+ file: str | None = None
48
+ line: int | None = None
49
+ rule: str | None = None
50
+ suggestion: str | None = None
51
+ timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
52
+ confidence: float = 1.0
53
+ extra: dict[str, Any] = field(default_factory=dict)
54
+
55
+ def to_dict(self) -> dict[str, Any]:
56
+ data = {
57
+ "severity": self.severity,
58
+ "category": self.category,
59
+ "title": self.title,
60
+ "message": self.message,
61
+ "file": self.file,
62
+ "line": self.line,
63
+ "rule": self.rule,
64
+ "suggestion": self.suggestion,
65
+ "timestamp": self.timestamp,
66
+ "confidence": self.confidence,
67
+ }
68
+ data.update(self.extra)
69
+ return data
70
+
71
+
72
+ def validate_findings(findings: list[Any]) -> list[str]:
73
+ """Validate agent findings against `FINDING_SCHEMA`. Returns a list of
74
+ warning strings — empty means valid. Mirrors
75
+ `BaseAgent.validate_output` so local and production checks agree."""
76
+ errors: list[str] = []
77
+ valid_severities = {"critical", "high", "medium", "low", "info", "error"}
78
+ for i, f in enumerate(findings):
79
+ if not isinstance(f, dict):
80
+ errors.append(f"finding[{i}] is not a dict (got {type(f).__name__})")
81
+ continue
82
+ for req in ("severity", "category", "title", "message"):
83
+ if req not in f:
84
+ errors.append(f"finding[{i}] missing required field '{req}'")
85
+ sev = f.get("severity")
86
+ if sev and sev not in valid_severities:
87
+ errors.append(f"finding[{i}] severity '{sev}' not in {valid_severities}")
88
+ conf = f.get("confidence")
89
+ if conf is not None:
90
+ try:
91
+ cf = float(conf)
92
+ if not (0.0 <= cf <= 1.0):
93
+ errors.append(f"finding[{i}] confidence {cf} out of range [0, 1]")
94
+ except (TypeError, ValueError):
95
+ errors.append(f"finding[{i}] confidence must be a float, got {type(conf).__name__}")
96
+ return errors