@kontourai/flow-agents 0.1.2 → 0.2.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 (85) hide show
  1. package/.github/dependabot.yml +23 -0
  2. package/.github/workflows/release-please.yml +31 -0
  3. package/.github/workflows/runtime-compat.yml +118 -0
  4. package/CHANGELOG.md +23 -0
  5. package/CONTRIBUTING.md +4 -0
  6. package/README.md +53 -10
  7. package/build/src/cli/init.js +215 -5
  8. package/build/src/cli/utterance-check.js +65 -1
  9. package/build/src/tools/build-universal-bundles.js +268 -0
  10. package/build/src/tools/filter-installed-packs.js +3 -0
  11. package/build/src/tools/validate-source-tree.js +5 -1
  12. package/context/scripts/telemetry/lib/config.sh +5 -1
  13. package/context/settings/flow-agents-settings.json +7 -0
  14. package/docs/context-map.md +1 -0
  15. package/docs/index.md +45 -4
  16. package/docs/integrations/conformance.md +246 -0
  17. package/docs/integrations/framework-adapter.md +275 -0
  18. package/docs/integrations/harness-install.md +213 -0
  19. package/docs/integrations/index.md +54 -0
  20. package/docs/north-star.md +2 -2
  21. package/docs/spec/runtime-hook-surface.md +472 -0
  22. package/docs/survey-utterance-check.md +211 -94
  23. package/docs/vision.md +45 -0
  24. package/evals/acceptance/run.sh +4 -2
  25. package/evals/acceptance/test_opencode_harness.sh +121 -0
  26. package/evals/acceptance/test_pi_harness.sh +98 -0
  27. package/evals/integration/test_bundle_install.sh +226 -1
  28. package/evals/integration/test_bundle_lifecycle.sh +641 -0
  29. package/evals/integration/test_utterance_check.sh +291 -44
  30. package/evals/run.sh +2 -0
  31. package/evals/static/test_universal_bundles.sh +137 -2
  32. package/integrations/strands/README.md +256 -0
  33. package/integrations/strands/example.py +74 -0
  34. package/integrations/strands/flow_agents_strands/__init__.py +27 -0
  35. package/integrations/strands/flow_agents_strands/hooks.py +194 -0
  36. package/integrations/strands/flow_agents_strands/policy.py +348 -0
  37. package/integrations/strands/flow_agents_strands/steering.py +172 -0
  38. package/integrations/strands/flow_agents_strands/telemetry.py +238 -0
  39. package/integrations/strands/pyproject.toml +38 -0
  40. package/integrations/strands/tests/__init__.py +0 -0
  41. package/integrations/strands/tests/test_hooks.py +304 -0
  42. package/integrations/strands/tests/test_policy.py +315 -0
  43. package/integrations/strands/tests/test_telemetry.py +184 -0
  44. package/integrations/strands-ts/README.md +224 -0
  45. package/integrations/strands-ts/bin/conformance-shim.mjs +257 -0
  46. package/integrations/strands-ts/package.json +53 -0
  47. package/integrations/strands-ts/src/hooks.ts +208 -0
  48. package/integrations/strands-ts/src/index.ts +22 -0
  49. package/integrations/strands-ts/src/policy.ts +345 -0
  50. package/integrations/strands-ts/src/telemetry.ts +251 -0
  51. package/integrations/strands-ts/test/test-policy.ts +322 -0
  52. package/integrations/strands-ts/test/test-telemetry.ts +226 -0
  53. package/integrations/strands-ts/tsconfig.json +20 -0
  54. package/package.json +7 -2
  55. package/packaging/conformance/README.md +142 -0
  56. package/packaging/conformance/fixtures/config-protection--allow-no-path.json +18 -0
  57. package/packaging/conformance/fixtures/config-protection--allow-safe-file.json +20 -0
  58. package/packaging/conformance/fixtures/config-protection--block-biome.json +20 -0
  59. package/packaging/conformance/fixtures/config-protection--block-eslintrc.json +20 -0
  60. package/packaging/conformance/fixtures/quality-gate--allow-no-path.json +17 -0
  61. package/packaging/conformance/fixtures/quality-gate--allow-nonexistent-file.json +19 -0
  62. package/packaging/conformance/fixtures/stop-goal-fit--allow-clean-cwd.json +17 -0
  63. package/packaging/conformance/fixtures/stop-goal-fit--block-strict-mode.json +23 -0
  64. package/packaging/conformance/fixtures/stop-goal-fit--warn-active-delivery.json +21 -0
  65. package/packaging/conformance/fixtures/workflow-steering--allow-no-state.json +16 -0
  66. package/packaging/conformance/fixtures/workflow-steering--inject-active-state.json +29 -0
  67. package/packaging/conformance/fixtures/workflow-steering--inject-subagent-steering.json +25 -0
  68. package/packaging/conformance/package.json +4 -0
  69. package/packaging/conformance/run-conformance.js +322 -0
  70. package/packaging/manifest.json +59 -0
  71. package/schemas/flow-agents-settings.schema.json +48 -0
  72. package/scripts/README.md +4 -0
  73. package/scripts/dogfood.js +16 -0
  74. package/scripts/hooks/opencode-hook-adapter.js +123 -0
  75. package/scripts/hooks/opencode-telemetry-hook.js +101 -0
  76. package/scripts/hooks/pi-hook-adapter.js +123 -0
  77. package/scripts/hooks/pi-telemetry-hook.js +105 -0
  78. package/scripts/hooks/run-hook.js +8 -0
  79. package/scripts/hooks/utterance-check.js +124 -22
  80. package/scripts/telemetry/lib/config.sh +5 -1
  81. package/src/cli/init.ts +219 -6
  82. package/src/cli/utterance-check.ts +71 -1
  83. package/src/tools/build-universal-bundles.ts +266 -0
  84. package/src/tools/filter-installed-packs.ts +3 -0
  85. package/src/tools/validate-source-tree.ts +5 -1
@@ -0,0 +1,256 @@
1
+ # flow-agents-strands
2
+
3
+ **SPIKE — Framework Adapter Proof of Concept**
4
+
5
+ This package proves the thesis: Flow Agents' process-discipline layer
6
+ (telemetry events + workflow steering + policy gates) can compile to a
7
+ **framework adapter** hook surface — here, AWS Strands Agents — not just
8
+ coding-agent harnesses.
9
+
10
+ ---
11
+
12
+ ## Harness adapters vs. framework adapters
13
+
14
+ The existing Flow Agents adapters (`claude-code/`, `codex/`, `kiro/`) are
15
+ **harness adapters**: they integrate with coding-agent runtimes that each have
16
+ their own hook format (JSON on stdin, exit codes, lifecycle events named by
17
+ the harness). Each adapter normalizes its harness's hook payloads into the
18
+ canonical Flow Agents telemetry taxonomy and then delegates to the shared
19
+ `scripts/telemetry/telemetry.sh` sink.
20
+
21
+ This package is a **framework adapter**: Strands Agents is not a coding-agent
22
+ harness — it is a general-purpose Python agent SDK. Its hook surface
23
+ (`HookProvider` / `HookRegistry`) is class-based and synchronous rather than
24
+ process-based. This means:
25
+
26
+ - No stdin/stdout protocol.
27
+ - No process exit codes as block signals.
28
+ - Hook callbacks receive typed Python event objects and can mutate them in
29
+ place (e.g. set `event.cancel_tool` to block a tool call).
30
+
31
+ Despite these surface differences, the **same canonical event taxonomy** is
32
+ used. The JSONL output from `FlowAgentsHooks` is structurally identical to
33
+ the output produced by `claude-telemetry-hook.js` and `codex-telemetry-hook.js`.
34
+
35
+ ---
36
+
37
+ ## Canonical event taxonomy
38
+
39
+ All telemetry events follow the schema defined in `scripts/telemetry/telemetry.sh`.
40
+ The Strands → canonical mapping is exposed as a module-level dict:
41
+
42
+ ```python
43
+ from flow_agents_strands import STRANDS_TO_CANONICAL
44
+ # {
45
+ # "AgentInitializedEvent": "agentSpawn",
46
+ # "BeforeInvocationEvent": "userPromptSubmit",
47
+ # "AfterInvocationEvent": "stop",
48
+ # "BeforeToolCallEvent": "preToolUse",
49
+ # "AfterToolCallEvent": "postToolUse",
50
+ # "AfterModelCallEvent": "postToolUse",
51
+ # "MessageAddedEvent": "userPromptSubmit",
52
+ # }
53
+ ```
54
+
55
+ Canonical names map to schema `event_type` values:
56
+
57
+ | Canonical name | Schema event_type |
58
+ |-----------------------|----------------------------|
59
+ | `agentSpawn` | `session.start` |
60
+ | `userPromptSubmit` | `turn.user` |
61
+ | `preToolUse` | `tool.invoke` |
62
+ | `permissionRequest` | `tool.permission_request` |
63
+ | `postToolUse` | `tool.result` |
64
+ | `stop` | `session.end` |
65
+
66
+ ---
67
+
68
+ ## Telemetry sink
69
+
70
+ Events are written to `.flow-agents/.telemetry/full.jsonl` by default,
71
+ matching the local-files sink convention in `scripts/telemetry/lib/config.sh`:
72
+
73
+ ```
74
+ TELEMETRY_CHANNEL_FULL_LOG_FILE = <data_dir>/full.jsonl
75
+ ```
76
+
77
+ The JSON record shape matches `build_base_event()` in `telemetry.sh`:
78
+
79
+ ```json
80
+ {
81
+ "schema_version": "0.3.0",
82
+ "timestamp": "1718000000000",
83
+ "session_id": "<uuid>",
84
+ "event_id": "<uuid>",
85
+ "event_type": "tool.invoke",
86
+ "agent": { "name": "strands-agent", "runtime": "strands", "version": "unknown" },
87
+ "hook": { "event_name": "preToolUse", "source": "strands", ... },
88
+ "tool": { "name": "edit", "normalized_name": "fs_write", "input": {...} }
89
+ }
90
+ ```
91
+
92
+ ---
93
+
94
+ ## Policy gates
95
+
96
+ The config-protection policy binds to the canonical Node.js engine
97
+ (`scripts/hooks/run-hook.js → config-protection.js`) via subprocess, consuming
98
+ the engine contract (contract_version "1.0") rather than reimplementing it.
99
+
100
+ **How it works:**
101
+
102
+ 1. On `BeforeToolCallEvent`, if the tool name is a write-like tool, the gate
103
+ serialises the event to the canonical JSON payload and spawns:
104
+ `node run-hook.js config-protection config-protection.js`
105
+ 2. The engine exits 2 (block) or 0 (allow). Exit code 2 causes `event.cancel_tool`
106
+ to be set to the block reason from stderr.
107
+ 3. All other exit codes fail-open (do not block the agent).
108
+
109
+ **Fallback when Node.js is unavailable:**
110
+
111
+ If `node` is not on PATH or the engine script (`run-hook.js`) cannot be located,
112
+ the gate degrades to a built-in Python implementation of the same logic and emits
113
+ a `RuntimeWarning`. The pure-Python implementation mirrors `PROTECTED_FILES` from
114
+ `config-protection.js` exactly. See §Limitations for the degradation contract.
115
+
116
+ **Custom protected_files:** if you pass a custom `frozenset` to `PolicyGate`,
117
+ Python evaluation is used directly (the engine subprocess cannot receive a
118
+ runtime-custom set). This is intended for tests and local override only.
119
+
120
+ On `BeforeToolCallEvent`, if the tool name is a write-like tool and the target
121
+ file is in the protected set, `event.cancel_tool` is set to the block reason.
122
+ Strands will cancel the call and surface the message as the tool result.
123
+
124
+ ---
125
+
126
+ ## Workflow steering
127
+
128
+ The JS `workflow-steering.js` hook injects steering text by appending it to
129
+ the prompt payload. Strands' `BeforeInvocationEvent` does **not** expose a
130
+ mutable system prompt at callback time.
131
+
132
+ **Spike approach:** call `hooks.steering_context()` at Agent construction and
133
+ append the result to the system prompt:
134
+
135
+ ```python
136
+ hooks = FlowAgentsHooks(workspace=".")
137
+ system_prompt = "You are a helpful agent.\n" + hooks.steering_context()
138
+ agent = Agent(system_prompt=system_prompt, hooks=[hooks])
139
+ ```
140
+
141
+ `steering_context()` also emits a `turn.user` telemetry event so the steering
142
+ injection is recorded in the JSONL log.
143
+
144
+ ---
145
+
146
+ ## Quickstart
147
+
148
+ ```python
149
+ from strands import Agent
150
+ from strands.models import BedrockModel
151
+ from flow_agents_strands import FlowAgentsHooks
152
+
153
+ # Build hooks — no strands import needed for this step
154
+ hooks = FlowAgentsHooks(
155
+ workspace=".", # root of your project (reads .flow-agents/)
156
+ agent_name="my-agent", # appears in telemetry events
157
+ )
158
+
159
+ # Load steering context BEFORE constructing the agent
160
+ system_prompt = (
161
+ "You are a helpful assistant.\n"
162
+ + hooks.steering_context() # appends workflow state reminders if any
163
+ )
164
+
165
+ # Wire hooks into the Agent
166
+ model = BedrockModel(model_id="anthropic.claude-3-5-sonnet-20241022-v2:0")
167
+ agent = Agent(model=model, system_prompt=system_prompt, hooks=[hooks])
168
+
169
+ result = agent("List the files in this directory.")
170
+ print(result)
171
+ ```
172
+
173
+ Telemetry is written to `.flow-agents/.telemetry/full.jsonl`.
174
+
175
+ ---
176
+
177
+ ## Installation
178
+
179
+ ```bash
180
+ # Without strands (for telemetry/policy use only, tests, etc.):
181
+ pip install flow-agents-strands
182
+
183
+ # With strands SDK:
184
+ pip install "flow-agents-strands[strands]"
185
+ ```
186
+
187
+ ---
188
+
189
+ ## Running tests
190
+
191
+ ```bash
192
+ cd integrations/strands
193
+ python3 -m unittest discover
194
+ ```
195
+
196
+ Tests use stdlib `unittest` only — no pytest, no strands-agents required.
197
+
198
+ ---
199
+
200
+ ## Limitations (honest spike notes)
201
+
202
+ 1. **Node.js subprocess dependency**: The primary policy binding spawns a Node.js
203
+ subprocess for each `BeforeToolCallEvent` that involves a write-like tool. If
204
+ `node` is not on PATH or the `@kontourai/flow-agents` package is not installed,
205
+ the gate degrades gracefully to the built-in Python fallback with a one-time
206
+ `RuntimeWarning`. The Python fallback uses the same `PROTECTED_FILES` constant
207
+ as the engine and is auditable. To force the subprocess path, set
208
+ `FLOW_AGENTS_ENGINE_PATH` to the absolute path of `run-hook.js`.
209
+
210
+ 2. **Steering seam**: Strands does not allow mutating the system prompt from
211
+ `BeforeInvocationEvent`. The workaround (`steering_context()` at Agent
212
+ construction) is a one-shot snapshot; it does not re-evaluate on every turn
213
+ the way the JS hook does at `UserPromptSubmit`. Productization would
214
+ require either a custom Strands model wrapper that injects context per-turn,
215
+ or upstream SDK support for mutable system-prompt context in the invocation
216
+ event.
217
+
218
+ 3. **session.usage event omitted**: The JS harness emits a `session.usage`
219
+ event on stop with token counts pulled from the transcript. The Strands
220
+ `AfterInvocationEvent` does not (yet) expose token-usage data in the hook
221
+ payload, so this event is not emitted. Productization would need to read
222
+ usage from the agent's response object and attach it here.
223
+
224
+ 4. **No analytics channel**: The harness adapters write to two channels
225
+ (full + analytics) with different redaction profiles. This spike writes
226
+ only to the `full` channel. Adding analytics is straightforward: a second
227
+ `TelemetrySink` instance pointed at `analytics.jsonl` with the analytics
228
+ redact list applied.
229
+
230
+ 5. **No Console/HTTP sink**: The bash transport supports POSTing events to a
231
+ Console endpoint. This adapter writes JSONL only. Adding HTTP transport
232
+ would mean replicating the `console_telemetry_emit()` logic in Python or
233
+ calling `transport.sh` as a subprocess.
234
+
235
+ 6. **Runtime version is "unknown"**: The harness adapters run
236
+ `<runtime> --version` to populate `agent.version`. Strands does not
237
+ expose its version through the hook event; `importlib.metadata` could
238
+ provide the SDK version as a proxy.
239
+
240
+ 7. **No subagent / delegation event**: The Strands SDK does not have a
241
+ built-in InvokeSubagents tool; the delegation telemetry path is not wired.
242
+
243
+ 8. **Quality-gate policy omitted**: `quality-gate.js` invokes ruff/biome
244
+ after edits. This is omitted from the spike because it requires executing
245
+ external formatters and has no clear Strands analogue yet.
246
+
247
+
248
+ ## What productization would require
249
+
250
+ - Upstream Strands SDK support for mutable per-turn context injection.
251
+ - Token-usage exposure in `AfterInvocationEvent` for `session.usage` events.
252
+ - Dual-channel JSONL + optional HTTP transport mirroring the bash transport.
253
+ - Packaging as a proper release with semantic versioning once the Strands hook
254
+ API stabilizes.
255
+ - Integration tests against a live Strands agent (currently blocked by missing
256
+ AWS credentials in CI).
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ example.py — Intended usage of FlowAgentsHooks with a real Strands Agent.
4
+
5
+ Guarded by try/except ImportError so it degrades gracefully when
6
+ strands-agents is not installed (e.g. in CI or unit-test environments).
7
+
8
+ Run this only when:
9
+ 1. strands-agents is installed: pip install "flow-agents-strands[strands]"
10
+ 2. AWS credentials are configured for Bedrock (or swap to a different model)
11
+ """
12
+
13
+ from flow_agents_strands import FlowAgentsHooks
14
+
15
+ # Step 1: Build hooks — no strands import required
16
+ hooks = FlowAgentsHooks(
17
+ workspace=".", # root of your project (reads .flow-agents/)
18
+ agent_name="example-agent",
19
+ )
20
+
21
+ # Step 2: Load steering context at agent construction time.
22
+ # This is the documented spike workaround for the system-prompt seam:
23
+ # BeforeInvocationEvent does not expose a mutable system_prompt in Strands,
24
+ # so we snapshot workflow state once and prepend it to the system prompt.
25
+ # See README.md § Limitations for details.
26
+ base_system_prompt = (
27
+ "You are a helpful assistant. "
28
+ "Follow the Flow Agents workflow discipline."
29
+ )
30
+ steering = hooks.steering_context()
31
+ system_prompt = base_system_prompt + steering
32
+
33
+ print("=== Flow Agents Strands Example ===")
34
+ print(f"Steering context ({len(steering)} chars):")
35
+ print(steering or "(none — no active workflow state found)")
36
+ print()
37
+
38
+ try:
39
+ from strands import Agent # type: ignore[import]
40
+ from strands.models import BedrockModel # type: ignore[import]
41
+
42
+ model = BedrockModel(
43
+ model_id="anthropic.claude-3-5-sonnet-20241022-v2:0",
44
+ region_name="us-east-1",
45
+ )
46
+
47
+ agent = Agent(
48
+ model=model,
49
+ system_prompt=system_prompt,
50
+ hooks=[hooks],
51
+ )
52
+
53
+ print("Agent created. Running a simple task...")
54
+ result = agent("List the Python files in the current directory.")
55
+ print("Result:", result)
56
+ print()
57
+ print("Telemetry written to .flow-agents/.telemetry/full.jsonl")
58
+
59
+ except ImportError:
60
+ print(
61
+ "strands-agents is not installed.\n"
62
+ "Install it to run against a live agent:\n"
63
+ " pip install 'flow-agents-strands[strands]'\n"
64
+ "\n"
65
+ "The hooks and telemetry modules work without strands-agents installed.\n"
66
+ "Run the unit tests with:\n"
67
+ " python3 -m unittest discover"
68
+ )
69
+ except Exception as exc:
70
+ print(f"Agent run failed (likely missing AWS credentials): {exc}")
71
+ print(
72
+ "\nThis example requires AWS credentials configured for Bedrock.\n"
73
+ "The hooks + telemetry code ran successfully up to the Agent() call."
74
+ )
@@ -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)