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.
- tenaxisos_sdk/__init__.py +14 -0
- tenaxisos_sdk/agents/__init__.py +20 -0
- tenaxisos_sdk/agents/base.py +277 -0
- tenaxisos_sdk/agents/context.py +132 -0
- tenaxisos_sdk/agents/findings.py +96 -0
- tenaxisos_sdk/agents/manifest.py +158 -0
- tenaxisos_sdk/cli/__init__.py +1 -0
- tenaxisos_sdk/cli/main.py +956 -0
- tenaxisos_sdk/core/__init__.py +9 -0
- tenaxisos_sdk/core/manifest.py +194 -0
- tenaxisos_sdk/core/schema.py +53 -0
- tenaxisos_sdk/core/schemas/manifest.v0.schema.json +198 -0
- tenaxisos_sdk/core/schemas/playbook.v0.schema.json +96 -0
- tenaxisos_sdk/core/signing.py +106 -0
- tenaxisos_sdk/host/__init__.py +111 -0
- tenaxisos_sdk/importers/__init__.py +10 -0
- tenaxisos_sdk/importers/n8n.py +313 -0
- tenaxisos_sdk/runtime/__init__.py +5 -0
- tenaxisos_sdk/runtime/adapters/__init__.py +3 -0
- tenaxisos_sdk/runtime/adapters/base.py +62 -0
- tenaxisos_sdk/runtime/adapters/langchain.py +161 -0
- tenaxisos_sdk/testing/__init__.py +3 -0
- tenaxisos_sdk/testing/local_host.py +90 -0
- tenaxisos_sdk/tools/__init__.py +12 -0
- tenaxisos_sdk/tools/base.py +66 -0
- tenaxisos_sdk/tools/manifest.py +105 -0
- tenaxisos_sdk/tools/schema.py +44 -0
- tenaxisos_sdk/tools/stdio.py +57 -0
- tenaxisos_sdk-0.2.0.dist-info/METADATA +131 -0
- tenaxisos_sdk-0.2.0.dist-info/RECORD +34 -0
- tenaxisos_sdk-0.2.0.dist-info/WHEEL +5 -0
- tenaxisos_sdk-0.2.0.dist-info/entry_points.txt +2 -0
- tenaxisos_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
- tenaxisos_sdk-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|