@kontourai/flow-agents 0.2.0 → 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.
Files changed (46) hide show
  1. package/.github/workflows/runtime-compat.yml +1 -1
  2. package/CHANGELOG.md +23 -0
  3. package/README.md +38 -19
  4. package/build/src/cli/flow-kit.js +9 -4
  5. package/build/src/cli/runtime-adapter.js +9 -5
  6. package/build/src/cli/telemetry-doctor.js +4 -1
  7. package/build/src/runtime-adapters.js +34 -0
  8. package/build/src/tools/build-universal-bundles.js +18 -1
  9. package/console.telemetry.json +115 -20
  10. package/docs/_layouts/default.html +2 -0
  11. package/docs/index.md +8 -0
  12. package/docs/integrations/index.md +4 -0
  13. package/docs/integrations/knowledge-kit-live.md +211 -0
  14. package/docs/kit-authoring-guide.md +169 -0
  15. package/docs/spec/runtime-hook-surface.md +56 -3
  16. package/evals/acceptance/run.sh +10 -1
  17. package/evals/acceptance/test_knowledge_kit_live.sh +221 -0
  18. package/evals/acceptance/test_pi_harness.sh +15 -0
  19. package/evals/integration/test_runtime_adapter_activation.sh +113 -1
  20. package/integrations/strands/examples/knowledge_kit_live.py +461 -0
  21. package/integrations/strands/flow_agents_strands/steering.py +54 -1
  22. package/integrations/strands/tests/test_hooks.py +88 -0
  23. package/integrations/strands-ts/src/hooks.ts +104 -0
  24. package/integrations/strands-ts/test/test-steering.ts +159 -0
  25. package/kits/catalog.json +6 -0
  26. package/kits/knowledge/adapters/default-store/index.js +821 -0
  27. package/kits/knowledge/adapters/flow-runner/index.js +1179 -0
  28. package/kits/knowledge/adapters/flow-runner/telemetry.js +174 -0
  29. package/kits/knowledge/docs/README.md +135 -0
  30. package/kits/knowledge/docs/store-contract.md +526 -0
  31. package/kits/knowledge/evals/consolidation/suite.test.js +1234 -0
  32. package/kits/knowledge/evals/contract-suite/suite.test.js +670 -0
  33. package/kits/knowledge/evals/ingest-compile/suite.test.js +574 -0
  34. package/kits/knowledge/evals/synthesis/suite.test.js +909 -0
  35. package/kits/knowledge/flows/compile.flow.json +60 -0
  36. package/kits/knowledge/flows/consolidate.flow.json +77 -0
  37. package/kits/knowledge/flows/ingest.flow.json +60 -0
  38. package/kits/knowledge/flows/store-contract.flow.json +48 -0
  39. package/kits/knowledge/flows/synthesize.flow.json +77 -0
  40. package/kits/knowledge/kit.json +78 -0
  41. package/package.json +1 -1
  42. package/src/cli/flow-kit.ts +10 -4
  43. package/src/cli/runtime-adapter.ts +10 -5
  44. package/src/cli/telemetry-doctor.ts +4 -1
  45. package/src/runtime-adapters.ts +35 -0
  46. package/src/tools/build-universal-bundles.ts +18 -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()
@@ -77,6 +77,37 @@ def _safe_text(value: Any, max_length: int = 240) -> str:
77
77
  return text[: max_length - 3] + "..."
78
78
 
79
79
 
80
+ def _read_strands_kit_flows(flow_agents_dir: Path) -> List[Dict[str, Any]]:
81
+ """
82
+ Scan .flow-agents/runtime/strands/flows/ for activated kit flow files.
83
+
84
+ Returns a list of dicts with keys: kit_id, asset_id, description.
85
+ Files are expected to be JSON with at least an "id" field (and optional
86
+ "description"). The path structure is flows/<kit-id>/<asset-id>.flow.json,
87
+ produced by activateStrandsLocal in src/runtime-adapters.ts (Issue #32,
88
+ Decision Q3: option (a) — new adapter id writes runtime files here so
89
+ the steering layer can discover them without knowing the catalog layout).
90
+ """
91
+ flows_dir = flow_agents_dir / "runtime" / "strands" / "flows"
92
+ if not flows_dir.exists():
93
+ return []
94
+ results: List[Dict[str, Any]] = []
95
+ for flow_file in sorted(flows_dir.rglob("*.flow.json")):
96
+ payload = _read_json(flow_file)
97
+ if payload is None:
98
+ continue
99
+ asset_id = payload.get("id") or flow_file.stem.replace(".flow", "")
100
+ description = payload.get("description") or ""
101
+ # Infer kit_id from the directory component between flows/ and the file
102
+ try:
103
+ rel_parts = flow_file.relative_to(flows_dir).parts
104
+ kit_id = rel_parts[0] if len(rel_parts) >= 2 else ""
105
+ except ValueError:
106
+ kit_id = ""
107
+ results.append({"kit_id": kit_id, "asset_id": asset_id, "description": description})
108
+ return results
109
+
110
+
80
111
  class SteeringContext:
81
112
  """
82
113
  Loads Flow Agents workflow-steering context from .flow-agents/ state files.
@@ -90,7 +121,8 @@ class SteeringContext:
90
121
  """
91
122
  Return a steering text string (possibly empty) for the current
92
123
  workflow state. Mirrors the stateSteering() + contextMapSteering()
93
- output from workflow-steering.js.
124
+ output from workflow-steering.js, and also surfaces activated kit
125
+ flows from the strands-local runtime path (Issue #32 AC2).
94
126
  """
95
127
  parts: List[str] = []
96
128
 
@@ -102,6 +134,10 @@ class SteeringContext:
102
134
  if ctx_hint:
103
135
  parts.append(ctx_hint)
104
136
 
137
+ kit_flows_hint = self._kit_flows_steering()
138
+ if kit_flows_hint:
139
+ parts.append(kit_flows_hint)
140
+
105
141
  if not parts:
106
142
  return ""
107
143
 
@@ -170,3 +206,20 @@ class SteeringContext:
170
206
  "If structure, commands, schemas, skills, agents, or packs changed, "
171
207
  "run `npm run context-map -- --check`."
172
208
  )
209
+
210
+ def _kit_flows_steering(self) -> str:
211
+ """
212
+ Surface activated kit flows from the strands-local runtime path.
213
+
214
+ Reads .flow-agents/runtime/strands/flows/ (written by `flow-kit activate
215
+ --adapter strands-local`) and emits a brief hint listing active kit flows
216
+ by id and description so the agent is aware of available workflow guidance.
217
+ """
218
+ flows = _read_strands_kit_flows(self._flow_agents_dir)
219
+ if not flows:
220
+ return ""
221
+ lines = ["KIT FLOWS: the following kit flows are activated for this workspace:"]
222
+ for flow in flows:
223
+ desc = f" — {_safe_text(flow['description'], 120)}" if flow.get("description") else ""
224
+ lines.append(f" • {flow['asset_id']}{desc}")
225
+ return "\n".join(lines)
@@ -302,3 +302,91 @@ class TestFlowAgentsHooksSteeringContext(unittest.TestCase):
302
302
 
303
303
  if __name__ == "__main__":
304
304
  unittest.main()
305
+
306
+ class TestSteeringContextKitFlows(unittest.TestCase):
307
+ """
308
+ Verify steering context surfaces activated kit flows from the strands-local
309
+ runtime path — Issue #32 AC2.
310
+
311
+ Fixture: a fake .flow.json file written to the path that activateStrandsLocal
312
+ produces (.flow-agents/runtime/strands/flows/<kit-id>/<asset-id>.flow.json).
313
+ """
314
+
315
+ def _write_fake_flow(self, workspace: Path, kit_id: str, asset_id: str, description: str = "") -> None:
316
+ flows_dir = workspace / ".flow-agents" / "runtime" / "strands" / "flows" / kit_id
317
+ flows_dir.mkdir(parents=True, exist_ok=True)
318
+ flow_file = flows_dir / f"{asset_id.replace('.', '-')}.flow.json"
319
+ payload = {"id": asset_id, "description": description}
320
+ flow_file.write_text(json.dumps(payload), encoding="utf-8")
321
+
322
+ def test_kit_flows_empty_when_no_runtime_dir(self):
323
+ """No runtime dir → no kit flows hint in steering context."""
324
+ from flow_agents_strands.steering import SteeringContext
325
+ with tempfile.TemporaryDirectory() as d:
326
+ ctx = SteeringContext(workspace=d)
327
+ result = ctx.load()
328
+ self.assertNotIn("KIT FLOWS", result)
329
+
330
+ def test_kit_flows_surfaced_when_runtime_flow_files_exist(self):
331
+ """Fake runtime flow file → kit flow id appears in steering context (AC2)."""
332
+ from flow_agents_strands.steering import SteeringContext
333
+ with tempfile.TemporaryDirectory() as d:
334
+ ws = Path(d)
335
+ self._write_fake_flow(ws, "builder", "builder.shape", "Shape a problem.")
336
+ ctx = SteeringContext(workspace=d)
337
+ result = ctx.load()
338
+ self.assertIn("KIT FLOWS", result)
339
+ self.assertIn("builder.shape", result)
340
+
341
+ def test_kit_flows_includes_description(self):
342
+ """Description from flow JSON appears in steering context."""
343
+ from flow_agents_strands.steering import SteeringContext
344
+ with tempfile.TemporaryDirectory() as d:
345
+ ws = Path(d)
346
+ self._write_fake_flow(ws, "builder", "builder.build", "Build a feature end-to-end.")
347
+ ctx = SteeringContext(workspace=d)
348
+ result = ctx.load()
349
+ self.assertIn("Build a feature end-to-end.", result)
350
+
351
+ def test_kit_flows_multiple_flows_all_listed(self):
352
+ """Multiple kit flows all appear in steering context."""
353
+ from flow_agents_strands.steering import SteeringContext
354
+ with tempfile.TemporaryDirectory() as d:
355
+ ws = Path(d)
356
+ self._write_fake_flow(ws, "builder", "builder.shape", "Shape.")
357
+ self._write_fake_flow(ws, "builder", "builder.build", "Build.")
358
+ ctx = SteeringContext(workspace=d)
359
+ result = ctx.load()
360
+ self.assertIn("builder.shape", result)
361
+ self.assertIn("builder.build", result)
362
+
363
+ def test_kit_flows_malformed_json_skipped(self):
364
+ """Malformed flow JSON does not crash steering; other flows still appear."""
365
+ from flow_agents_strands.steering import SteeringContext
366
+ with tempfile.TemporaryDirectory() as d:
367
+ ws = Path(d)
368
+ self._write_fake_flow(ws, "builder", "builder.shape", "Shape.")
369
+ # Write a malformed flow file
370
+ bad_dir = ws / ".flow-agents" / "runtime" / "strands" / "flows" / "builder"
371
+ bad_dir.mkdir(parents=True, exist_ok=True)
372
+ (bad_dir / "bad.flow.json").write_text("{ not valid json", encoding="utf-8")
373
+ ctx = SteeringContext(workspace=d)
374
+ result = ctx.load()
375
+ # builder.shape should still appear; no crash
376
+ self.assertIn("builder.shape", result)
377
+
378
+ def test_hooks_steering_context_surfaces_kit_flows(self):
379
+ """FlowAgentsHooks.steering_context() surfaces kit flows (AC2 via hooks layer)."""
380
+ with tempfile.TemporaryDirectory() as d:
381
+ ws = Path(d)
382
+ flows_dir = ws / ".flow-agents" / "runtime" / "strands" / "flows" / "builder"
383
+ flows_dir.mkdir(parents=True)
384
+ (flows_dir / "builder-shape.flow.json").write_text(
385
+ json.dumps({"id": "builder.shape", "description": "Shape a problem."}),
386
+ encoding="utf-8",
387
+ )
388
+ from flow_agents_strands import FlowAgentsHooks
389
+ hooks = FlowAgentsHooks(sink_path=d, workspace=d)
390
+ ctx = hooks.steering_context()
391
+ self.assertIn("KIT FLOWS", ctx)
392
+ self.assertIn("builder.shape", ctx)