@kontourai/flow-agents 0.1.2 → 0.3.0
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.
- package/.github/dependabot.yml +23 -0
- package/.github/workflows/release-please.yml +31 -0
- package/.github/workflows/runtime-compat.yml +118 -0
- package/CHANGELOG.md +46 -0
- package/CONTRIBUTING.md +4 -0
- package/README.md +80 -18
- package/build/src/cli/flow-kit.js +9 -4
- package/build/src/cli/init.js +215 -5
- package/build/src/cli/runtime-adapter.js +9 -5
- package/build/src/cli/telemetry-doctor.js +4 -1
- package/build/src/cli/utterance-check.js +65 -1
- package/build/src/runtime-adapters.js +34 -0
- package/build/src/tools/build-universal-bundles.js +285 -0
- package/build/src/tools/filter-installed-packs.js +3 -0
- package/build/src/tools/validate-source-tree.js +5 -1
- package/console.telemetry.json +115 -20
- package/context/scripts/telemetry/lib/config.sh +5 -1
- package/context/settings/flow-agents-settings.json +7 -0
- package/docs/_layouts/default.html +2 -0
- package/docs/context-map.md +1 -0
- package/docs/index.md +53 -4
- package/docs/integrations/conformance.md +246 -0
- package/docs/integrations/framework-adapter.md +275 -0
- package/docs/integrations/harness-install.md +213 -0
- package/docs/integrations/index.md +58 -0
- package/docs/integrations/knowledge-kit-live.md +211 -0
- package/docs/kit-authoring-guide.md +169 -0
- package/docs/north-star.md +2 -2
- package/docs/spec/runtime-hook-surface.md +525 -0
- package/docs/survey-utterance-check.md +211 -94
- package/docs/vision.md +45 -0
- package/evals/acceptance/run.sh +13 -2
- package/evals/acceptance/test_knowledge_kit_live.sh +221 -0
- package/evals/acceptance/test_opencode_harness.sh +121 -0
- package/evals/acceptance/test_pi_harness.sh +113 -0
- package/evals/integration/test_bundle_install.sh +226 -1
- package/evals/integration/test_bundle_lifecycle.sh +641 -0
- package/evals/integration/test_runtime_adapter_activation.sh +113 -1
- package/evals/integration/test_utterance_check.sh +291 -44
- package/evals/run.sh +2 -0
- package/evals/static/test_universal_bundles.sh +137 -2
- package/integrations/strands/README.md +256 -0
- package/integrations/strands/example.py +74 -0
- package/integrations/strands/examples/knowledge_kit_live.py +461 -0
- package/integrations/strands/flow_agents_strands/__init__.py +27 -0
- package/integrations/strands/flow_agents_strands/hooks.py +194 -0
- package/integrations/strands/flow_agents_strands/policy.py +348 -0
- package/integrations/strands/flow_agents_strands/steering.py +225 -0
- package/integrations/strands/flow_agents_strands/telemetry.py +238 -0
- package/integrations/strands/pyproject.toml +38 -0
- package/integrations/strands/tests/__init__.py +0 -0
- package/integrations/strands/tests/test_hooks.py +392 -0
- package/integrations/strands/tests/test_policy.py +315 -0
- package/integrations/strands/tests/test_telemetry.py +184 -0
- package/integrations/strands-ts/README.md +224 -0
- package/integrations/strands-ts/bin/conformance-shim.mjs +257 -0
- package/integrations/strands-ts/package.json +53 -0
- package/integrations/strands-ts/src/hooks.ts +312 -0
- package/integrations/strands-ts/src/index.ts +22 -0
- package/integrations/strands-ts/src/policy.ts +345 -0
- package/integrations/strands-ts/src/telemetry.ts +251 -0
- package/integrations/strands-ts/test/test-policy.ts +322 -0
- package/integrations/strands-ts/test/test-steering.ts +159 -0
- package/integrations/strands-ts/test/test-telemetry.ts +226 -0
- package/integrations/strands-ts/tsconfig.json +20 -0
- package/kits/catalog.json +6 -0
- package/kits/knowledge/adapters/default-store/index.js +821 -0
- package/kits/knowledge/adapters/flow-runner/index.js +1179 -0
- package/kits/knowledge/adapters/flow-runner/telemetry.js +174 -0
- package/kits/knowledge/docs/README.md +135 -0
- package/kits/knowledge/docs/store-contract.md +526 -0
- package/kits/knowledge/evals/consolidation/suite.test.js +1234 -0
- package/kits/knowledge/evals/contract-suite/suite.test.js +670 -0
- package/kits/knowledge/evals/ingest-compile/suite.test.js +574 -0
- package/kits/knowledge/evals/synthesis/suite.test.js +909 -0
- package/kits/knowledge/flows/compile.flow.json +60 -0
- package/kits/knowledge/flows/consolidate.flow.json +77 -0
- package/kits/knowledge/flows/ingest.flow.json +60 -0
- package/kits/knowledge/flows/store-contract.flow.json +48 -0
- package/kits/knowledge/flows/synthesize.flow.json +77 -0
- package/kits/knowledge/kit.json +78 -0
- package/package.json +7 -2
- package/packaging/conformance/README.md +142 -0
- package/packaging/conformance/fixtures/config-protection--allow-no-path.json +18 -0
- package/packaging/conformance/fixtures/config-protection--allow-safe-file.json +20 -0
- package/packaging/conformance/fixtures/config-protection--block-biome.json +20 -0
- package/packaging/conformance/fixtures/config-protection--block-eslintrc.json +20 -0
- package/packaging/conformance/fixtures/quality-gate--allow-no-path.json +17 -0
- package/packaging/conformance/fixtures/quality-gate--allow-nonexistent-file.json +19 -0
- package/packaging/conformance/fixtures/stop-goal-fit--allow-clean-cwd.json +17 -0
- package/packaging/conformance/fixtures/stop-goal-fit--block-strict-mode.json +23 -0
- package/packaging/conformance/fixtures/stop-goal-fit--warn-active-delivery.json +21 -0
- package/packaging/conformance/fixtures/workflow-steering--allow-no-state.json +16 -0
- package/packaging/conformance/fixtures/workflow-steering--inject-active-state.json +29 -0
- package/packaging/conformance/fixtures/workflow-steering--inject-subagent-steering.json +25 -0
- package/packaging/conformance/package.json +4 -0
- package/packaging/conformance/run-conformance.js +322 -0
- package/packaging/manifest.json +59 -0
- package/schemas/flow-agents-settings.schema.json +48 -0
- package/scripts/README.md +4 -0
- package/scripts/dogfood.js +16 -0
- package/scripts/hooks/opencode-hook-adapter.js +123 -0
- package/scripts/hooks/opencode-telemetry-hook.js +101 -0
- package/scripts/hooks/pi-hook-adapter.js +123 -0
- package/scripts/hooks/pi-telemetry-hook.js +105 -0
- package/scripts/hooks/run-hook.js +8 -0
- package/scripts/hooks/utterance-check.js +124 -22
- package/scripts/telemetry/lib/config.sh +5 -1
- package/src/cli/flow-kit.ts +10 -4
- package/src/cli/init.ts +219 -6
- package/src/cli/runtime-adapter.ts +10 -5
- package/src/cli/telemetry-doctor.ts +4 -1
- package/src/cli/utterance-check.ts +71 -1
- package/src/runtime-adapters.ts +35 -0
- package/src/tools/build-universal-bundles.ts +283 -0
- package/src/tools/filter-installed-packs.ts +3 -0
- package/src/tools/validate-source-tree.ts +5 -1
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
knowledge_kit_live.py — Live end-to-end example: Knowledge Kit S5 (keyless).
|
|
4
|
+
|
|
5
|
+
Demonstrates the full knowledge.ingest + knowledge.compile pipeline against a
|
|
6
|
+
real Strands agent backed by OllamaModel (qwen3:1.7b, no API key required).
|
|
7
|
+
|
|
8
|
+
What this script does:
|
|
9
|
+
1. Builds a temporary workspace with a fresh knowledge store.
|
|
10
|
+
2. Reads three short doc snippets from this repo's docs/ as the corpus.
|
|
11
|
+
3. Creates all three raw knowledge records via direct Node.js subprocess calls
|
|
12
|
+
to the kit's flow-runner — the programmatic path is reliable at any model
|
|
13
|
+
size and exercises the full ingest flow including gate telemetry.
|
|
14
|
+
4. Runs one Strands agent turn that calls capture_knowledge for a final
|
|
15
|
+
integration snippet — proving the Strands tool pathway works and generating
|
|
16
|
+
FlowAgentsHooks session telemetry (session.start / tool.invoke / tool.result).
|
|
17
|
+
5. Runs one Strands agent turn that calls compile_knowledge with all four raw
|
|
18
|
+
record IDs, producing a compiled record with verified provenance links.
|
|
19
|
+
6. Prints resulting record IDs, provenance link verification, and telemetry
|
|
20
|
+
event types.
|
|
21
|
+
|
|
22
|
+
Two telemetry streams are asserted:
|
|
23
|
+
- Kit gate events (tool.invoke + tool.result per ingest/compile gate point):
|
|
24
|
+
<workspace>/.telemetry/full.jsonl written by the Node flow-runner
|
|
25
|
+
- Session events (session.start / turn.user / tool.invoke / tool.result /
|
|
26
|
+
session.end from FlowAgentsHooks):
|
|
27
|
+
<workspace>/.flow-agents/.telemetry/full.jsonl
|
|
28
|
+
|
|
29
|
+
Design note — programmatic captures + agent-driven compile:
|
|
30
|
+
qwen3:1.7b (1.7B parameters) reliably calls single-tool prompts for compile
|
|
31
|
+
(passing 3 explicit UUID args) but has occasional failures on capture when the
|
|
32
|
+
agent produces an empty turn. Using programmatic captures for the bulk of the
|
|
33
|
+
corpus (3 records) ensures reliable kit telemetry evidence, while the agent
|
|
34
|
+
still calls capture_knowledge once and compile_knowledge once to prove the
|
|
35
|
+
full Strands tool pathway. If the agent-driven capture fails (empty turn), the
|
|
36
|
+
script falls back to a programmatic capture so the compile step always has
|
|
37
|
+
enough records.
|
|
38
|
+
|
|
39
|
+
Limitations:
|
|
40
|
+
- qwen3:1.7b occasionally produces empty turns (no tool call, no text). The
|
|
41
|
+
acceptance harness tolerates this for the capture step by using a fallback
|
|
42
|
+
programmatic capture. The compile step uses explicit UUID args and is reliable.
|
|
43
|
+
- Single-turn scope per step.
|
|
44
|
+
- Strands steering seam: system_prompt is injected once at Agent construction.
|
|
45
|
+
- Kit telemetry and FlowAgentsHooks telemetry write to separate JSONL paths.
|
|
46
|
+
|
|
47
|
+
Usage:
|
|
48
|
+
FLOW_AGENTS_ROOT=$(pwd) \\
|
|
49
|
+
/tmp/strands-py-live/venv/bin/python3 \\
|
|
50
|
+
integrations/strands/examples/knowledge_kit_live.py
|
|
51
|
+
|
|
52
|
+
# Ollama must be running before this script is called:
|
|
53
|
+
# ollama serve &
|
|
54
|
+
# ollama pull qwen3:1.7b
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import json
|
|
60
|
+
import os
|
|
61
|
+
import re
|
|
62
|
+
import subprocess
|
|
63
|
+
import sys
|
|
64
|
+
import tempfile
|
|
65
|
+
import textwrap
|
|
66
|
+
import time
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
from typing import List
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Resolve repo root
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
_REPO_ROOT_ENV = os.environ.get("FLOW_AGENTS_ROOT", "")
|
|
75
|
+
if _REPO_ROOT_ENV:
|
|
76
|
+
REPO_ROOT = Path(_REPO_ROOT_ENV).resolve()
|
|
77
|
+
else:
|
|
78
|
+
_here = Path(__file__).resolve().parent
|
|
79
|
+
REPO_ROOT = _here.parent.parent.parent
|
|
80
|
+
|
|
81
|
+
if not (REPO_ROOT / "kits" / "knowledge").exists():
|
|
82
|
+
print(
|
|
83
|
+
f"ERROR: could not locate knowledge kit at {REPO_ROOT}/kits/knowledge\n"
|
|
84
|
+
"Set FLOW_AGENTS_ROOT=/path/to/flow-agents and retry.",
|
|
85
|
+
file=sys.stderr,
|
|
86
|
+
)
|
|
87
|
+
sys.exit(1)
|
|
88
|
+
|
|
89
|
+
FLOW_RUNNER = REPO_ROOT / "kits" / "knowledge" / "adapters" / "flow-runner" / "index.js"
|
|
90
|
+
DEFAULT_STORE = REPO_ROOT / "kits" / "knowledge" / "adapters" / "default-store" / "index.js"
|
|
91
|
+
STRANDS_PKG = REPO_ROOT / "integrations" / "strands"
|
|
92
|
+
|
|
93
|
+
if str(STRANDS_PKG) not in sys.path:
|
|
94
|
+
sys.path.insert(0, str(STRANDS_PKG))
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Node.js bridge
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
_NODE_BRIDGE_TEMPLATE = textwrap.dedent("""\
|
|
101
|
+
// Auto-generated bridge — do not edit.
|
|
102
|
+
import {{ DefaultKnowledgeStore }} from "{default_store}";
|
|
103
|
+
import {{ capture, compile }} from "{flow_runner}";
|
|
104
|
+
|
|
105
|
+
const [,, cmd, ...rest] = process.argv;
|
|
106
|
+
const workspace = process.env.FLOW_AGENTS_WORKSPACE || process.cwd();
|
|
107
|
+
const storeRoot = workspace + "/.knowledge-store";
|
|
108
|
+
const store = new DefaultKnowledgeStore({{ storeRoot }});
|
|
109
|
+
|
|
110
|
+
async function main() {{
|
|
111
|
+
if (cmd === "capture") {{
|
|
112
|
+
const rawText = rest[0];
|
|
113
|
+
const meta = rest[1] ? JSON.parse(rest[1]) : {{}};
|
|
114
|
+
const result = await capture(rawText, meta, {{ store, workspace }});
|
|
115
|
+
process.stdout.write(JSON.stringify({{ id: result.id }}) + "\\n");
|
|
116
|
+
}} else if (cmd === "compile") {{
|
|
117
|
+
const rawIds = JSON.parse(rest[0]);
|
|
118
|
+
const result = await compile(rawIds, {{ store, workspace }});
|
|
119
|
+
process.stdout.write(JSON.stringify({{ id: result.id }}) + "\\n");
|
|
120
|
+
}} else {{
|
|
121
|
+
process.stderr.write("Unknown command: " + cmd + "\\n");
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}}
|
|
124
|
+
}}
|
|
125
|
+
|
|
126
|
+
main().catch((err) => {{
|
|
127
|
+
process.stderr.write(err.message + "\\n");
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}});
|
|
130
|
+
""")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _write_node_bridge(workspace: Path) -> Path:
|
|
134
|
+
bridge_path = workspace / "_kit_bridge.mjs"
|
|
135
|
+
bridge_src = _NODE_BRIDGE_TEMPLATE.format(
|
|
136
|
+
default_store=str(DEFAULT_STORE),
|
|
137
|
+
flow_runner=str(FLOW_RUNNER),
|
|
138
|
+
)
|
|
139
|
+
bridge_path.write_text(bridge_src, encoding="utf-8")
|
|
140
|
+
return bridge_path
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _call_node_bridge(bridge: Path, cmd: str, *args: str, workspace: Path) -> dict:
|
|
144
|
+
env = {**os.environ, "FLOW_AGENTS_WORKSPACE": str(workspace)}
|
|
145
|
+
result = subprocess.run(
|
|
146
|
+
["node", str(bridge), cmd, *args],
|
|
147
|
+
capture_output=True, text=True, env=env, timeout=30,
|
|
148
|
+
)
|
|
149
|
+
if result.returncode != 0:
|
|
150
|
+
raise RuntimeError(
|
|
151
|
+
f"kit bridge {cmd} failed (exit {result.returncode}):\n"
|
|
152
|
+
f"stdout: {result.stdout[:400]}\nstderr: {result.stderr[:400]}"
|
|
153
|
+
)
|
|
154
|
+
lines = [l.strip() for l in result.stdout.strip().splitlines() if l.strip()]
|
|
155
|
+
for line in reversed(lines):
|
|
156
|
+
try:
|
|
157
|
+
return json.loads(line)
|
|
158
|
+
except json.JSONDecodeError:
|
|
159
|
+
continue
|
|
160
|
+
raise RuntimeError(f"kit bridge {cmd} produced no parseable JSON:\n{result.stdout[:400]}")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
# Strands tool definitions
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def make_tools(bridge: Path, workspace: Path):
|
|
168
|
+
try:
|
|
169
|
+
from strands import tool # type: ignore[import]
|
|
170
|
+
except ImportError as exc:
|
|
171
|
+
raise ImportError("Install: pip install 'strands-agents[ollama]'") from exc
|
|
172
|
+
|
|
173
|
+
@tool
|
|
174
|
+
def capture_knowledge(text: str, category: str) -> str:
|
|
175
|
+
"""
|
|
176
|
+
Capture a raw knowledge text snippet into the knowledge store.
|
|
177
|
+
category must be dot-separated lowercase, e.g. 'engineering.docs'.
|
|
178
|
+
Returns JSON: {"id": "<uuid>"}.
|
|
179
|
+
"""
|
|
180
|
+
meta_json = json.dumps({"category": category})
|
|
181
|
+
data = _call_node_bridge(bridge, "capture", text, meta_json, workspace=workspace)
|
|
182
|
+
return json.dumps(data)
|
|
183
|
+
|
|
184
|
+
@tool
|
|
185
|
+
def compile_knowledge(id1: str, id2: str, id3: str) -> str:
|
|
186
|
+
"""
|
|
187
|
+
Compile three raw knowledge records into a compiled record with provenance.
|
|
188
|
+
Pass the three raw record UUIDs as separate id1, id2, id3 arguments.
|
|
189
|
+
Returns JSON: {"id": "<compiled-uuid>"}.
|
|
190
|
+
"""
|
|
191
|
+
raw_ids = [i for i in [id1, id2, id3] if i and i.strip()]
|
|
192
|
+
data = _call_node_bridge(bridge, "compile", json.dumps(raw_ids), workspace=workspace)
|
|
193
|
+
return json.dumps(data)
|
|
194
|
+
|
|
195
|
+
return [capture_knowledge, compile_knowledge]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
# Corpus
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def _read_corpus(repo_root: Path, max_chars: int = 350) -> List[dict]:
|
|
203
|
+
candidates = [
|
|
204
|
+
("docs/integrations/framework-adapter.md", "engineering.docs"),
|
|
205
|
+
("docs/integrations/index.md", "engineering.docs"),
|
|
206
|
+
("kits/knowledge/docs/README.md", "research.notes"),
|
|
207
|
+
]
|
|
208
|
+
corpus = []
|
|
209
|
+
for rel, category in candidates:
|
|
210
|
+
p = repo_root / rel
|
|
211
|
+
if p.exists():
|
|
212
|
+
raw = p.read_text(encoding="utf-8", errors="replace")
|
|
213
|
+
snippet = " ".join(raw.split())[:max_chars].strip()
|
|
214
|
+
corpus.append({"path": rel, "text": snippet, "category": category})
|
|
215
|
+
if len(corpus) >= 3:
|
|
216
|
+
break
|
|
217
|
+
if len(corpus) < 3:
|
|
218
|
+
for p in sorted((repo_root / "docs").rglob("*.md"))[:5]:
|
|
219
|
+
if len(corpus) >= 3:
|
|
220
|
+
break
|
|
221
|
+
rel = str(p.relative_to(repo_root))
|
|
222
|
+
if not any(c["path"] == rel for c in corpus):
|
|
223
|
+
raw = p.read_text(encoding="utf-8", errors="replace")
|
|
224
|
+
corpus.append({
|
|
225
|
+
"path": rel,
|
|
226
|
+
"text": " ".join(raw.split())[:max_chars].strip(),
|
|
227
|
+
"category": "engineering.docs",
|
|
228
|
+
})
|
|
229
|
+
return corpus[:3]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# Helpers
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
def _extract_uuids(text: str) -> List[str]:
|
|
237
|
+
return re.findall(
|
|
238
|
+
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
|
|
239
|
+
str(text),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _read_store_ids_by_type(workspace: Path, record_type: str) -> List[str]:
|
|
244
|
+
store_path = workspace / ".knowledge-store" / "records"
|
|
245
|
+
if not store_path.exists():
|
|
246
|
+
return []
|
|
247
|
+
ids = []
|
|
248
|
+
for md_file in sorted(store_path.glob("*.md")):
|
|
249
|
+
content = md_file.read_text(encoding="utf-8", errors="replace")
|
|
250
|
+
if f"type: {record_type}" in content:
|
|
251
|
+
ids.append(md_file.stem)
|
|
252
|
+
return ids
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _read_telemetry(path: Path) -> List[dict]:
|
|
256
|
+
if not path.exists():
|
|
257
|
+
return []
|
|
258
|
+
events = []
|
|
259
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
260
|
+
line = line.strip()
|
|
261
|
+
if not line:
|
|
262
|
+
continue
|
|
263
|
+
try:
|
|
264
|
+
events.append(json.loads(line))
|
|
265
|
+
except json.JSONDecodeError:
|
|
266
|
+
pass
|
|
267
|
+
return events
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
# Main
|
|
272
|
+
# ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
def main() -> None:
|
|
275
|
+
print("=== Knowledge Kit S5: Keyless Live Example ===")
|
|
276
|
+
print(f"Repo root: {REPO_ROOT}")
|
|
277
|
+
print()
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
node_ver = subprocess.check_output(["node", "--version"], text=True).strip()
|
|
281
|
+
print(f"Node.js: {node_ver}")
|
|
282
|
+
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
|
283
|
+
print(f"ERROR: node not found: {exc}", file=sys.stderr)
|
|
284
|
+
sys.exit(1)
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
from strands import Agent # type: ignore[import]
|
|
288
|
+
from strands.models.ollama import OllamaModel # type: ignore[import]
|
|
289
|
+
except ImportError as exc:
|
|
290
|
+
print(f"ERROR: strands-agents[ollama] not installed: {exc}", file=sys.stderr)
|
|
291
|
+
sys.exit(1)
|
|
292
|
+
|
|
293
|
+
# --- Workspace ---
|
|
294
|
+
workspace = Path(tempfile.mkdtemp(prefix="knowledge-kit-live-"))
|
|
295
|
+
print(f"Workspace: {workspace}")
|
|
296
|
+
bridge = _write_node_bridge(workspace)
|
|
297
|
+
|
|
298
|
+
# --- Corpus ---
|
|
299
|
+
corpus = _read_corpus(REPO_ROOT)
|
|
300
|
+
if len(corpus) < 3:
|
|
301
|
+
print(f"ERROR: need at least 3 doc snippets, found {len(corpus)}", file=sys.stderr)
|
|
302
|
+
sys.exit(1)
|
|
303
|
+
print(f"Corpus: {len(corpus)} doc snippets")
|
|
304
|
+
for item in corpus:
|
|
305
|
+
print(f" {item['path']} ({item['category']})")
|
|
306
|
+
print()
|
|
307
|
+
|
|
308
|
+
# --- Step 1: Programmatic captures (3 records, reliable) ---
|
|
309
|
+
print("--- Step 1: Programmatic captures (3 records via Node bridge) ---")
|
|
310
|
+
prog_ids = []
|
|
311
|
+
for item in corpus:
|
|
312
|
+
data = _call_node_bridge(
|
|
313
|
+
bridge, "capture", item["text"],
|
|
314
|
+
json.dumps({"category": item["category"]}),
|
|
315
|
+
workspace=workspace,
|
|
316
|
+
)
|
|
317
|
+
prog_ids.append(data["id"])
|
|
318
|
+
print(f" {item['path']} → {data['id']}")
|
|
319
|
+
print()
|
|
320
|
+
|
|
321
|
+
# --- Step 2: Agent-driven capture (proves Strands tool pathway) ---
|
|
322
|
+
print("--- Step 2: Agent-driven capture (Strands tool pathway) ---")
|
|
323
|
+
tools = make_tools(bridge, workspace)
|
|
324
|
+
from flow_agents_strands import FlowAgentsHooks # type: ignore[import]
|
|
325
|
+
|
|
326
|
+
hooks = FlowAgentsHooks(workspace=str(workspace), agent_name="knowledge-kit-live")
|
|
327
|
+
model = OllamaModel(host="http://localhost:11434", model_id="qwen3:1.7b")
|
|
328
|
+
agent = Agent(model=model, tools=tools, hooks=[hooks], callback_handler=None)
|
|
329
|
+
|
|
330
|
+
agent_capture_text = "Strands agent integration proof: knowledge kit live example"
|
|
331
|
+
cap_prompt = (
|
|
332
|
+
f"You MUST call capture_knowledge right now. "
|
|
333
|
+
f"Use text={agent_capture_text!r} and category='engineering.docs'. "
|
|
334
|
+
f"Reply with the id you receive."
|
|
335
|
+
)
|
|
336
|
+
t0 = time.monotonic()
|
|
337
|
+
try:
|
|
338
|
+
cap_result = agent(cap_prompt)
|
|
339
|
+
except Exception as exc:
|
|
340
|
+
cap_result = None
|
|
341
|
+
print(f" Agent turn raised: {type(exc).__name__}", file=sys.stderr)
|
|
342
|
+
cap_elapsed = time.monotonic() - t0
|
|
343
|
+
print(f" Agent turn: {cap_elapsed:.1f}s")
|
|
344
|
+
print(f" Reply snippet: {str(cap_result)[:100]!r}")
|
|
345
|
+
|
|
346
|
+
# Check whether the agent called the tool; fall back to programmatic if not
|
|
347
|
+
raw_ids = _read_store_ids_by_type(workspace, "raw")
|
|
348
|
+
agent_called_capture = len(raw_ids) > len(prog_ids)
|
|
349
|
+
if agent_called_capture:
|
|
350
|
+
print(f" Agent called capture_knowledge (total raw records: {len(raw_ids)})")
|
|
351
|
+
else:
|
|
352
|
+
# Fallback: add the record programmatically so compile always has material
|
|
353
|
+
data = _call_node_bridge(
|
|
354
|
+
bridge, "capture", agent_capture_text,
|
|
355
|
+
json.dumps({"category": "engineering.docs"}),
|
|
356
|
+
workspace=workspace,
|
|
357
|
+
)
|
|
358
|
+
raw_ids = _read_store_ids_by_type(workspace, "raw")
|
|
359
|
+
print(f" Agent did not call tool (empty turn) — fallback capture via bridge → {data['id']}")
|
|
360
|
+
print()
|
|
361
|
+
|
|
362
|
+
# --- Step 3: Agent-driven compile ---
|
|
363
|
+
print("--- Step 3: Agent-driven compile ---")
|
|
364
|
+
# Use at most 3 IDs for the compile tool (signature accepts id1, id2, id3)
|
|
365
|
+
r1, r2, r3 = raw_ids[0], raw_ids[1], raw_ids[2]
|
|
366
|
+
compile_prompt = (
|
|
367
|
+
f'You MUST call compile_knowledge right now. '
|
|
368
|
+
f'Pass id1="{r1}", id2="{r2}", id3="{r3}". '
|
|
369
|
+
f'Reply with the compiled id.'
|
|
370
|
+
)
|
|
371
|
+
t0 = time.monotonic()
|
|
372
|
+
try:
|
|
373
|
+
compile_result = agent(compile_prompt)
|
|
374
|
+
except Exception as exc:
|
|
375
|
+
compile_result = None
|
|
376
|
+
print(f" Agent turn raised: {type(exc).__name__}", file=sys.stderr)
|
|
377
|
+
compile_elapsed = time.monotonic() - t0
|
|
378
|
+
print(f" Agent turn: {compile_elapsed:.1f}s")
|
|
379
|
+
print(f" Reply snippet: {str(compile_result)[:100]!r}")
|
|
380
|
+
|
|
381
|
+
compiled_ids = _read_store_ids_by_type(workspace, "compiled")
|
|
382
|
+
if not compiled_ids:
|
|
383
|
+
# Fallback: compile programmatically
|
|
384
|
+
print(" Agent did not call compile_knowledge — fallback via bridge")
|
|
385
|
+
data = _call_node_bridge(bridge, "compile", json.dumps([r1, r2, r3]), workspace=workspace)
|
|
386
|
+
compiled_ids = [data["id"]]
|
|
387
|
+
print(f" Fallback compiled id: {data['id']}")
|
|
388
|
+
else:
|
|
389
|
+
print(f" Compiled records in store: {len(compiled_ids)}")
|
|
390
|
+
print()
|
|
391
|
+
|
|
392
|
+
# --- Provenance verification ---
|
|
393
|
+
print("--- Provenance verification ---")
|
|
394
|
+
source_ids_ok = False
|
|
395
|
+
graph_ok = False
|
|
396
|
+
if compiled_ids:
|
|
397
|
+
compiled_path = workspace / ".knowledge-store" / "records" / f"{compiled_ids[0]}.md"
|
|
398
|
+
compiled_content = compiled_path.read_text(encoding="utf-8")
|
|
399
|
+
source_ids_ok = all(rid in compiled_content for rid in [r1, r2, r3])
|
|
400
|
+
print(f" Compiled record: {compiled_ids[0]}")
|
|
401
|
+
print(f" Source IDs in provenance: {source_ids_ok}")
|
|
402
|
+
graph_path = workspace / ".knowledge-store" / "graph-index.json"
|
|
403
|
+
if graph_path.exists():
|
|
404
|
+
graph = json.loads(graph_path.read_text())
|
|
405
|
+
fwd = graph.get("forward", {}).get(compiled_ids[0], [])
|
|
406
|
+
source_links = [l for l in fwd if l.get("kind") == "source"]
|
|
407
|
+
graph_ok = len(source_links) >= 3
|
|
408
|
+
print(f" Source links in graph index: {len(source_links)}")
|
|
409
|
+
print()
|
|
410
|
+
|
|
411
|
+
# --- Telemetry ---
|
|
412
|
+
kit_tel_path = workspace / ".telemetry" / "full.jsonl"
|
|
413
|
+
session_tel_path = workspace / ".flow-agents" / ".telemetry" / "full.jsonl"
|
|
414
|
+
|
|
415
|
+
kit_events = _read_telemetry(kit_tel_path)
|
|
416
|
+
session_events = _read_telemetry(session_tel_path)
|
|
417
|
+
|
|
418
|
+
print(f"Kit gate telemetry ({kit_tel_path.relative_to(workspace)}): {len(kit_events)} events")
|
|
419
|
+
for ev in kit_events:
|
|
420
|
+
tool_name = ev.get("tool", {}).get("name", "")
|
|
421
|
+
print(f" [{ev.get('event_type')}] {tool_name}")
|
|
422
|
+
|
|
423
|
+
print()
|
|
424
|
+
print(f"Session telemetry ({session_tel_path.relative_to(workspace)}): {len(session_events)} events")
|
|
425
|
+
for ev in session_events:
|
|
426
|
+
tool_name = ev.get("tool", {}).get("name", "")
|
|
427
|
+
suffix = f" ({tool_name})" if tool_name else ""
|
|
428
|
+
print(f" [{ev.get('event_type')}]{suffix}")
|
|
429
|
+
|
|
430
|
+
# --- Summary ---
|
|
431
|
+
kit_types = sorted({ev.get("event_type") for ev in kit_events})
|
|
432
|
+
session_types = sorted({ev.get("event_type") for ev in session_events})
|
|
433
|
+
|
|
434
|
+
print()
|
|
435
|
+
print("--- Summary ---")
|
|
436
|
+
print(f"Kit event types: {kit_types}")
|
|
437
|
+
print(f"Session event types: {session_types}")
|
|
438
|
+
print(f"Raw records: {len(raw_ids)}")
|
|
439
|
+
print(f"Compiled records: {len(compiled_ids)}")
|
|
440
|
+
print(f"Provenance ok: {source_ids_ok}")
|
|
441
|
+
print(f"Agent called capture: {agent_called_capture}")
|
|
442
|
+
print()
|
|
443
|
+
|
|
444
|
+
ok = (
|
|
445
|
+
len(raw_ids) >= 3
|
|
446
|
+
and len(compiled_ids) >= 1
|
|
447
|
+
and source_ids_ok
|
|
448
|
+
and "tool.invoke" in kit_types
|
|
449
|
+
and "tool.result" in kit_types
|
|
450
|
+
and "session.start" in session_types
|
|
451
|
+
)
|
|
452
|
+
print(f"Overall: {'PASS' if ok else 'FAIL'}")
|
|
453
|
+
print(f"Workspace: {workspace}")
|
|
454
|
+
print()
|
|
455
|
+
|
|
456
|
+
if not ok:
|
|
457
|
+
sys.exit(1)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
if __name__ == "__main__":
|
|
461
|
+
main()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
flow_agents_strands — Flow Agents framework adapter for AWS Strands Agents.
|
|
3
|
+
|
|
4
|
+
Provides FlowAgentsHooks, a HookProvider (duck-typed so strands-agents is
|
|
5
|
+
optional at import time) that wires Flow Agents' canonical telemetry events,
|
|
6
|
+
policy gates, and workflow-steering context into the Strands hook surface.
|
|
7
|
+
|
|
8
|
+
Importable without strands-agents installed:
|
|
9
|
+
|
|
10
|
+
from flow_agents_strands import FlowAgentsHooks
|
|
11
|
+
hooks = FlowAgentsHooks() # no strands needed yet
|
|
12
|
+
ctx = hooks.steering_context() # load steering context anywhere
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .hooks import FlowAgentsHooks
|
|
16
|
+
from .telemetry import STRANDS_TO_CANONICAL, TelemetrySink
|
|
17
|
+
from .policy import PolicyGate
|
|
18
|
+
from .steering import SteeringContext
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"FlowAgentsHooks",
|
|
22
|
+
"STRANDS_TO_CANONICAL",
|
|
23
|
+
"TelemetrySink",
|
|
24
|
+
"PolicyGate",
|
|
25
|
+
"SteeringContext",
|
|
26
|
+
]
|
|
27
|
+
__version__ = "0.0.1"
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
hooks.py — FlowAgentsHooks: the main HookProvider for AWS Strands Agents.
|
|
3
|
+
|
|
4
|
+
Design: duck-typed so strands-agents is NOT required at import time.
|
|
5
|
+
The class uses TYPE_CHECKING guards and string-based isinstance() avoidance so
|
|
6
|
+
the full module tree is importable and unit-testable without the SDK installed.
|
|
7
|
+
|
|
8
|
+
When strands-agents IS installed, FlowAgentsHooks is a valid HookProvider
|
|
9
|
+
because it implements the register_hooks(registry, **kwargs) protocol method.
|
|
10
|
+
|
|
11
|
+
Usage (with strands installed):
|
|
12
|
+
|
|
13
|
+
from strands import Agent
|
|
14
|
+
from flow_agents_strands import FlowAgentsHooks
|
|
15
|
+
|
|
16
|
+
hooks = FlowAgentsHooks(workspace=".")
|
|
17
|
+
system_prompt = "You are a helpful agent." + hooks.steering_context()
|
|
18
|
+
agent = Agent(system_prompt=system_prompt, hooks=[hooks])
|
|
19
|
+
|
|
20
|
+
Usage (without strands, e.g. in tests):
|
|
21
|
+
|
|
22
|
+
from flow_agents_strands import FlowAgentsHooks
|
|
23
|
+
hooks = FlowAgentsHooks()
|
|
24
|
+
ctx = hooks.steering_context() # works without strands
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import time
|
|
30
|
+
from typing import Any, Callable, Dict, Optional, TYPE_CHECKING
|
|
31
|
+
|
|
32
|
+
from .telemetry import TelemetrySink, STRANDS_TO_CANONICAL
|
|
33
|
+
from .policy import PolicyGate
|
|
34
|
+
from .steering import SteeringContext
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
# These imports only run during static type-checking (mypy/pyright).
|
|
38
|
+
# At runtime the try/except below handles the optional SDK.
|
|
39
|
+
from strands.hooks import ( # type: ignore[import]
|
|
40
|
+
HookRegistry,
|
|
41
|
+
BeforeInvocationEvent,
|
|
42
|
+
AfterInvocationEvent,
|
|
43
|
+
BeforeToolCallEvent,
|
|
44
|
+
AfterToolCallEvent,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class FlowAgentsHooks:
|
|
49
|
+
"""
|
|
50
|
+
Flow Agents HookProvider for AWS Strands Agents.
|
|
51
|
+
|
|
52
|
+
Implements the strands HookProvider protocol (register_hooks) via duck
|
|
53
|
+
typing. When strands-agents is not installed the class still fully
|
|
54
|
+
constructs and is usable for telemetry emission and steering context
|
|
55
|
+
loading.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
sink_path: Directory or file path for JSONL telemetry output.
|
|
59
|
+
Default: <workspace>/.flow-agents/.telemetry/full.jsonl
|
|
60
|
+
workspace: Root of the workspace to discover .flow-agents/ from.
|
|
61
|
+
Default: current working directory.
|
|
62
|
+
agent_name: Agent identifier embedded in telemetry events.
|
|
63
|
+
runtime: Runtime label embedded in telemetry events.
|
|
64
|
+
policy_gate: Optional PolicyGate instance; defaults to PolicyGate().
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
sink_path: Optional[str] = None,
|
|
70
|
+
workspace: Optional[str] = None,
|
|
71
|
+
agent_name: str = "strands-agent",
|
|
72
|
+
runtime: str = "strands",
|
|
73
|
+
policy_gate: Optional[PolicyGate] = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
self._sink = TelemetrySink(
|
|
76
|
+
sink_path=sink_path,
|
|
77
|
+
workspace=workspace,
|
|
78
|
+
agent_name=agent_name,
|
|
79
|
+
runtime=runtime,
|
|
80
|
+
)
|
|
81
|
+
self._policy = policy_gate if policy_gate is not None else PolicyGate()
|
|
82
|
+
self._steering = SteeringContext(workspace=workspace)
|
|
83
|
+
self._session_start_ts: Optional[float] = None
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
# Public API available WITHOUT strands installed
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def steering_context(self) -> str:
|
|
90
|
+
"""
|
|
91
|
+
Return workflow-steering context text for the current workspace.
|
|
92
|
+
|
|
93
|
+
Callers should append this to the Agent's system prompt at construction
|
|
94
|
+
time — e.g.:
|
|
95
|
+
|
|
96
|
+
system_prompt = base_prompt + hooks.steering_context()
|
|
97
|
+
|
|
98
|
+
This is the documented spike approach because Strands'
|
|
99
|
+
BeforeInvocationEvent does not expose a mutable system_prompt.
|
|
100
|
+
See README.md § Limitations.
|
|
101
|
+
"""
|
|
102
|
+
text = self._steering.load()
|
|
103
|
+
if text:
|
|
104
|
+
self._sink.emit_steering(text)
|
|
105
|
+
return text
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
# HookProvider protocol (register_hooks)
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def register_hooks(self, registry: Any, **kwargs: Any) -> None:
|
|
112
|
+
"""
|
|
113
|
+
Register Flow Agents callbacks with a Strands HookRegistry.
|
|
114
|
+
|
|
115
|
+
This method is the sole method required by the HookProvider protocol.
|
|
116
|
+
The registry parameter is typed as Any so the module compiles without
|
|
117
|
+
strands-agents installed; at runtime the real HookRegistry is passed.
|
|
118
|
+
"""
|
|
119
|
+
# Import lazily — only reachable when strands IS installed.
|
|
120
|
+
try:
|
|
121
|
+
from strands.hooks import ( # type: ignore[import]
|
|
122
|
+
BeforeInvocationEvent,
|
|
123
|
+
AfterInvocationEvent,
|
|
124
|
+
BeforeToolCallEvent,
|
|
125
|
+
AfterToolCallEvent,
|
|
126
|
+
AgentInitializedEvent,
|
|
127
|
+
)
|
|
128
|
+
except ImportError as exc:
|
|
129
|
+
raise ImportError(
|
|
130
|
+
"strands-agents is required to register hooks. "
|
|
131
|
+
"Install it with: pip install flow-agents-strands[strands]"
|
|
132
|
+
) from exc
|
|
133
|
+
|
|
134
|
+
registry.add_callback(AgentInitializedEvent, self._on_agent_initialized)
|
|
135
|
+
registry.add_callback(BeforeInvocationEvent, self._on_before_invocation)
|
|
136
|
+
registry.add_callback(AfterInvocationEvent, self._on_after_invocation)
|
|
137
|
+
registry.add_callback(BeforeToolCallEvent, self._on_before_tool_call)
|
|
138
|
+
registry.add_callback(AfterToolCallEvent, self._on_after_tool_call)
|
|
139
|
+
|
|
140
|
+
# ------------------------------------------------------------------
|
|
141
|
+
# Private callbacks
|
|
142
|
+
# ------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def _on_agent_initialized(self, event: Any) -> None:
|
|
145
|
+
"""AgentInitializedEvent → agentSpawn / session.start"""
|
|
146
|
+
self._session_start_ts = time.monotonic()
|
|
147
|
+
self._sink.emit_session_start()
|
|
148
|
+
|
|
149
|
+
def _on_before_invocation(self, event: Any) -> None:
|
|
150
|
+
"""BeforeInvocationEvent → userPromptSubmit / turn.user"""
|
|
151
|
+
if self._session_start_ts is None:
|
|
152
|
+
self._session_start_ts = time.monotonic()
|
|
153
|
+
self._sink.emit("userPromptSubmit")
|
|
154
|
+
|
|
155
|
+
def _on_after_invocation(self, event: Any) -> None:
|
|
156
|
+
"""AfterInvocationEvent → stop / session.end"""
|
|
157
|
+
duration_s = 0.0
|
|
158
|
+
if self._session_start_ts is not None:
|
|
159
|
+
duration_s = time.monotonic() - self._session_start_ts
|
|
160
|
+
self._sink.emit_session_end(duration_s=duration_s)
|
|
161
|
+
|
|
162
|
+
def _on_before_tool_call(self, event: Any) -> None:
|
|
163
|
+
"""
|
|
164
|
+
BeforeToolCallEvent → preToolUse / tool.invoke + policy gate.
|
|
165
|
+
|
|
166
|
+
If the policy gate blocks the call, sets event.cancel_tool to the
|
|
167
|
+
block reason (Strands will cancel the tool and return the message
|
|
168
|
+
as the tool result).
|
|
169
|
+
"""
|
|
170
|
+
tool_use = getattr(event, "tool_use", {}) or {}
|
|
171
|
+
tool_name = tool_use.get("name", "")
|
|
172
|
+
tool_input = tool_use.get("input", {}) or {}
|
|
173
|
+
|
|
174
|
+
# Emit telemetry first (fail-open: policy check follows)
|
|
175
|
+
self._sink.emit_tool_invoke(tool_name=tool_name, tool_input=tool_input)
|
|
176
|
+
|
|
177
|
+
# Policy gate
|
|
178
|
+
block_reason = self._policy.check_tool_call(
|
|
179
|
+
tool_name=tool_name,
|
|
180
|
+
tool_input=tool_input,
|
|
181
|
+
)
|
|
182
|
+
if block_reason:
|
|
183
|
+
try:
|
|
184
|
+
event.cancel_tool = block_reason
|
|
185
|
+
except AttributeError:
|
|
186
|
+
# Some event mock or future SDK change; log and continue
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
def _on_after_tool_call(self, event: Any) -> None:
|
|
190
|
+
"""AfterToolCallEvent → postToolUse / tool.result"""
|
|
191
|
+
tool_use = getattr(event, "tool_use", {}) or {}
|
|
192
|
+
tool_name = tool_use.get("name", "")
|
|
193
|
+
result = getattr(event, "result", None)
|
|
194
|
+
self._sink.emit_tool_result(tool_name=tool_name, tool_output=result)
|