agent-flywheel 0.1.0__tar.gz

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 (111) hide show
  1. agent_flywheel-0.1.0/.gitignore +58 -0
  2. agent_flywheel-0.1.0/LICENSE +21 -0
  3. agent_flywheel-0.1.0/PKG-INFO +429 -0
  4. agent_flywheel-0.1.0/README.md +365 -0
  5. agent_flywheel-0.1.0/examples/goose/README.md +78 -0
  6. agent_flywheel-0.1.0/examples/goose/demo_capture.py +90 -0
  7. agent_flywheel-0.1.0/examples/goose/serve_goose.py +321 -0
  8. agent_flywheel-0.1.0/examples/prime_agent/README.md +208 -0
  9. agent_flywheel-0.1.0/examples/prime_agent/route.ts +91 -0
  10. agent_flywheel-0.1.0/examples/prime_agent/serve_prime.py +551 -0
  11. agent_flywheel-0.1.0/pyproject.toml +146 -0
  12. agent_flywheel-0.1.0/schema/flywheel-1.json +432 -0
  13. agent_flywheel-0.1.0/scripts/cross_language_abi_check.sh +191 -0
  14. agent_flywheel-0.1.0/src/agent_flywheel/__init__.py +1067 -0
  15. agent_flywheel-0.1.0/src/agent_flywheel/_http.py +176 -0
  16. agent_flywheel-0.1.0/src/agent_flywheel/adapters/__init__.py +49 -0
  17. agent_flywheel-0.1.0/src/agent_flywheel/adapters/adk.py +268 -0
  18. agent_flywheel-0.1.0/src/agent_flywheel/adapters/langchain.py +288 -0
  19. agent_flywheel-0.1.0/src/agent_flywheel/adapters/prime_agent.py +1232 -0
  20. agent_flywheel-0.1.0/src/agent_flywheel/binding.py +360 -0
  21. agent_flywheel-0.1.0/src/agent_flywheel/child.py +63 -0
  22. agent_flywheel-0.1.0/src/agent_flywheel/cli.py +228 -0
  23. agent_flywheel-0.1.0/src/agent_flywheel/contract.py +411 -0
  24. agent_flywheel-0.1.0/src/agent_flywheel/endpoint.py +225 -0
  25. agent_flywheel-0.1.0/src/agent_flywheel/entrypoint.py +146 -0
  26. agent_flywheel-0.1.0/src/agent_flywheel/errors.py +87 -0
  27. agent_flywheel-0.1.0/src/agent_flywheel/event_stream.py +281 -0
  28. agent_flywheel-0.1.0/src/agent_flywheel/evidence.py +140 -0
  29. agent_flywheel-0.1.0/src/agent_flywheel/execution_identity.py +217 -0
  30. agent_flywheel-0.1.0/src/agent_flywheel/export.py +344 -0
  31. agent_flywheel-0.1.0/src/agent_flywheel/host_description.py +870 -0
  32. agent_flywheel-0.1.0/src/agent_flywheel/hosts/__init__.py +53 -0
  33. agent_flywheel-0.1.0/src/agent_flywheel/hosts/goose/plugin.json +7 -0
  34. agent_flywheel-0.1.0/src/agent_flywheel/hosts/scripts/percepteye_record.py +494 -0
  35. agent_flywheel-0.1.0/src/agent_flywheel/hosts/scripts/percepteye_report.py +192 -0
  36. agent_flywheel-0.1.0/src/agent_flywheel/install_hooks.py +301 -0
  37. agent_flywheel-0.1.0/src/agent_flywheel/introspection.py +619 -0
  38. agent_flywheel-0.1.0/src/agent_flywheel/isolation.py +227 -0
  39. agent_flywheel-0.1.0/src/agent_flywheel/outcomes.py +831 -0
  40. agent_flywheel-0.1.0/src/agent_flywheel/policy.py +490 -0
  41. agent_flywheel-0.1.0/src/agent_flywheel/production.py +1589 -0
  42. agent_flywheel-0.1.0/src/agent_flywheel/prompt.py +178 -0
  43. agent_flywheel-0.1.0/src/agent_flywheel/runner.py +1623 -0
  44. agent_flywheel-0.1.0/src/agent_flywheel/tracing.py +279 -0
  45. agent_flywheel-0.1.0/src/agent_flywheel/transport.py +328 -0
  46. agent_flywheel-0.1.0/tests/__init__.py +0 -0
  47. agent_flywheel-0.1.0/tests/conftest.py +26 -0
  48. agent_flywheel-0.1.0/tests/fixture_agent.py +178 -0
  49. agent_flywheel-0.1.0/tests/fixtures_agent.py +40 -0
  50. agent_flywheel-0.1.0/tests/node_sdk.py +97 -0
  51. agent_flywheel-0.1.0/tests/test_adk_adapter.py +325 -0
  52. agent_flywheel-0.1.0/tests/test_apply_current_policy.py +135 -0
  53. agent_flywheel-0.1.0/tests/test_architecture_svg_matches_its_renderer.py +53 -0
  54. agent_flywheel-0.1.0/tests/test_artifact_inline_bodies.py +221 -0
  55. agent_flywheel-0.1.0/tests/test_attach_consent_gate.py +179 -0
  56. agent_flywheel-0.1.0/tests/test_attach_production_capture.py +334 -0
  57. agent_flywheel-0.1.0/tests/test_binding.py +196 -0
  58. agent_flywheel-0.1.0/tests/test_capture_cost.py +285 -0
  59. agent_flywheel-0.1.0/tests/test_capture_verdict_parity.py +194 -0
  60. agent_flywheel-0.1.0/tests/test_cli_says_what_is_missing.py +135 -0
  61. agent_flywheel-0.1.0/tests/test_command_entrypoint.py +137 -0
  62. agent_flywheel-0.1.0/tests/test_completion_gate.py +132 -0
  63. agent_flywheel-0.1.0/tests/test_contract.py +221 -0
  64. agent_flywheel-0.1.0/tests/test_current_prompt.py +100 -0
  65. agent_flywheel-0.1.0/tests/test_documented_counts_are_true.py +130 -0
  66. agent_flywheel-0.1.0/tests/test_endpoint_follows_mode.py +284 -0
  67. agent_flywheel-0.1.0/tests/test_entity_identity_evidence.py +353 -0
  68. agent_flywheel-0.1.0/tests/test_entrypoint_and_outcomes.py +148 -0
  69. agent_flywheel-0.1.0/tests/test_event_stream_tool_calls.py +586 -0
  70. agent_flywheel-0.1.0/tests/test_excluded_rollouts_keep_their_evidence.py +240 -0
  71. agent_flywheel-0.1.0/tests/test_execution_identity.py +305 -0
  72. agent_flywheel-0.1.0/tests/test_export_matches_the_upload_path.py +157 -0
  73. agent_flywheel-0.1.0/tests/test_export_records.py +226 -0
  74. agent_flywheel-0.1.0/tests/test_goose_integration.py +370 -0
  75. agent_flywheel-0.1.0/tests/test_host_description.py +508 -0
  76. agent_flywheel-0.1.0/tests/test_host_description_wire.py +313 -0
  77. agent_flywheel-0.1.0/tests/test_install_hooks.py +298 -0
  78. agent_flywheel-0.1.0/tests/test_introspection.py +213 -0
  79. agent_flywheel-0.1.0/tests/test_isolation.py +206 -0
  80. agent_flywheel-0.1.0/tests/test_langchain_adapter.py +266 -0
  81. agent_flywheel-0.1.0/tests/test_langgraph_introspection.py +317 -0
  82. agent_flywheel-0.1.0/tests/test_lease_and_concurrency.py +624 -0
  83. agent_flywheel-0.1.0/tests/test_missing_control_plane_is_named.py +144 -0
  84. agent_flywheel-0.1.0/tests/test_mode_switch.py +308 -0
  85. agent_flywheel-0.1.0/tests/test_node_figures_match.py +55 -0
  86. agent_flywheel-0.1.0/tests/test_one_control_plane_spelling.py +75 -0
  87. agent_flywheel-0.1.0/tests/test_oss_boundary.py +305 -0
  88. agent_flywheel-0.1.0/tests/test_otel_egress.py +546 -0
  89. agent_flywheel-0.1.0/tests/test_packaging.py +128 -0
  90. agent_flywheel-0.1.0/tests/test_policy_delivery.py +135 -0
  91. agent_flywheel-0.1.0/tests/test_policy_source.py +458 -0
  92. agent_flywheel-0.1.0/tests/test_prime_agent_example.py +402 -0
  93. agent_flywheel-0.1.0/tests/test_prime_harness_snapshot.py +695 -0
  94. agent_flywheel-0.1.0/tests/test_production_turn_upload.py +785 -0
  95. agent_flywheel-0.1.0/tests/test_production_upload_producer.py +703 -0
  96. agent_flywheel-0.1.0/tests/test_prompt_decision.py +148 -0
  97. agent_flywheel-0.1.0/tests/test_prompt_decision_parity.py +212 -0
  98. agent_flywheel-0.1.0/tests/test_readme_renders_on_github.py +124 -0
  99. agent_flywheel-0.1.0/tests/test_report_turns_wire.py +353 -0
  100. agent_flywheel-0.1.0/tests/test_rollout_worker_identity_transport.py +99 -0
  101. agent_flywheel-0.1.0/tests/test_serve_registration.py +196 -0
  102. agent_flywheel-0.1.0/tests/test_served_model_is_servable.py +151 -0
  103. agent_flywheel-0.1.0/tests/test_subprocess_roundtrip.py +171 -0
  104. agent_flywheel-0.1.0/tests/test_substrate_unavailable.py +72 -0
  105. agent_flywheel-0.1.0/tests/test_task_argv_and_endpoint_env.py +127 -0
  106. agent_flywheel-0.1.0/tests/test_tool_call_id.py +219 -0
  107. agent_flywheel-0.1.0/tests/test_tool_calls_belong_to_their_own_turn.py +173 -0
  108. agent_flywheel-0.1.0/tests/test_tool_read_only_trait.py +80 -0
  109. agent_flywheel-0.1.0/tests/test_trajectory_is_crash_tolerant.py +134 -0
  110. agent_flywheel-0.1.0/tests/test_turn_text_capture.py +159 -0
  111. agent_flywheel-0.1.0/tests/test_zero_instrumentation.py +231 -0
@@ -0,0 +1,58 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .mypy_cache/
11
+ .coverage
12
+ htmlcov/
13
+ .percepteye-artifacts/
14
+ # ...and the misspellings the harness has actually written. All three appeared
15
+ # in one working tree: the artifacts root comes from a runtime path that is
16
+ # spelled differently in different places, so ignoring only the correct one
17
+ # left two directories of rollout output untracked-but-visible, which is how
18
+ # they end up in a `git add -A`. Directories, not file globs — a whole tree of
19
+ # `rl_<id>/` solve scripts is unambiguously agent output, so the reasoning
20
+ # below about not swallowing a future requirements.txt does not apply to them.
21
+ .perceptye-artifacts/
22
+ .percept-eye-artifacts/
23
+ _percepteye-artifacts/
24
+ .DS_Store
25
+
26
+ # Files an agent wrote into the repo root.
27
+ #
28
+ # A CLI agent's cwd IS its collected-artifacts directory, so running an example
29
+ # or a test from the repo root leaves the agent's output files here. Twenty of
30
+ # them were committed once by a `git add -A` -- 426KB of `xxxx` padding at the
31
+ # root of the SDK we ship.
32
+ #
33
+ # Enumerated, not globbed: `/*.txt` would silently swallow a future
34
+ # requirements.txt and `/*.sh` a future bootstrap script, and a gitignore that
35
+ # quietly drops a file someone wanted is a worse bug than the one it prevents.
36
+ # The durable fix is to run examples from a scratch directory rather than to
37
+ # extend this list forever.
38
+ /a.txt
39
+ /b.bin
40
+ /big.txt
41
+ /blob.bin
42
+ /hi.txt
43
+ /kept.txt
44
+ /answer.txt
45
+ /triage.sh
46
+ /f[0-9][0-9].txt
47
+ /avg_response_time.sh
48
+ /extract_ips.sh
49
+ /last_trace.sh
50
+ /merge_logs.sh
51
+ .worktrees/
52
+
53
+ # Cross-language ABI check scratch output (CI writes this, never committed)
54
+ abi/
55
+
56
+ # The built documentation site. Generated from website/docs by mkdocs;
57
+ # the source is tracked, the output is not.
58
+ website/site/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PerceptEye
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,429 @@
1
+ Metadata-Version: 2.5
2
+ Name: agent-flywheel
3
+ Version: 0.1.0
4
+ Summary: Self-improving agents through continual learning: in-process agentic RL that turns your own agent's work into training data. Observed tool outcomes, outbound-only, one runtime dependency.
5
+ Project-URL: Homepage, https://github.com/percepteye-ai/agent-flywheel
6
+ Project-URL: Documentation, https://github.com/percepteye-ai/agent-flywheel/tree/main/website/docs
7
+ Project-URL: Source, https://github.com/percepteye-ai/agent-flywheel
8
+ Project-URL: Issues, https://github.com/percepteye-ai/agent-flywheel/issues
9
+ Author: PerceptEye
10
+ Maintainer: PerceptEye
11
+ License: MIT License
12
+
13
+ Copyright (c) 2026 PerceptEye
14
+
15
+ Permission is hereby granted, free of charge, to any person obtaining a copy
16
+ of this software and associated documentation files (the "Software"), to deal
17
+ in the Software without restriction, including without limitation the rights
18
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
19
+ copies of the Software, and to permit persons to whom the Software is
20
+ furnished to do so, subject to the following conditions:
21
+
22
+ The above copyright notice and this permission notice shall be included in all
23
+ copies or substantial portions of the Software.
24
+
25
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
28
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
29
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
30
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
31
+ SOFTWARE.
32
+ License-File: LICENSE
33
+ Keywords: agent-evals,agent-rl,agentic-ai,agents,evaluation,flywheel,google-adk,langchain,langgraph,llm,llmops,observability,opentelemetry,reinforcement-fine-tuning,reinforcement-learning,rl-environments,rlvr,rollout,self-hosted
34
+ Classifier: Development Status :: 3 - Alpha
35
+ Classifier: Intended Audience :: Developers
36
+ Classifier: License :: OSI Approved :: MIT License
37
+ Classifier: Programming Language :: Python :: 3
38
+ Classifier: Programming Language :: Python :: 3.10
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3.12
41
+ Classifier: Programming Language :: Python :: 3.13
42
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
43
+ Classifier: Typing :: Typed
44
+ Requires-Python: >=3.10
45
+ Requires-Dist: httpx>=0.27
46
+ Provides-Extra: adapters-test
47
+ Requires-Dist: google-adk>=2.2; extra == 'adapters-test'
48
+ Requires-Dist: langchain-core>=0.3; extra == 'adapters-test'
49
+ Requires-Dist: langgraph>=1.0; extra == 'adapters-test'
50
+ Provides-Extra: dev
51
+ Requires-Dist: hatchling>=1.24; extra == 'dev'
52
+ Requires-Dist: jsonschema>=4.22; extra == 'dev'
53
+ Requires-Dist: mypy>=1.8; extra == 'dev'
54
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
55
+ Requires-Dist: pytest>=7.4; extra == 'dev'
56
+ Requires-Dist: ruff==0.16.2; extra == 'dev'
57
+ Provides-Extra: docs
58
+ Requires-Dist: mkdocs-material>=9.7; extra == 'docs'
59
+ Provides-Extra: otel
60
+ Requires-Dist: opentelemetry-api>=1.20; extra == 'otel'
61
+ Provides-Extra: otel-test
62
+ Requires-Dist: opentelemetry-sdk>=1.20; extra == 'otel-test'
63
+ Description-Content-Type: text/markdown
64
+
65
+ <div align="center">
66
+
67
+ <!-- IMAGE PATHS ARE RELATIVE ON PURPOSE, and it is a one-way door while this
68
+ repo is private. GitHub resolves a relative src against the repo you are
69
+ already authenticated to, so these render. An absolute
70
+ https://raw.githubusercontent.com/... src does NOT: raw.githubusercontent
71
+ is fetched anonymously and does not carry your github.com session, so a
72
+ PRIVATE repo answers 404 and every image on this page breaks -- for the
73
+ owner too, looking at their own README. Verified 2026-09-06: all four
74
+ raw.githubusercontent URLs a previous edit introduced returned 404, as did
75
+ the repo page itself.
76
+
77
+ This has now regressed TWICE, both times reasoning from PyPI: README.md is
78
+ copied into PKG-INFO and relative paths do not resolve there. That is true
79
+ and it is not the trade-off we are making. GitHub is the target surface for
80
+ these figures while the repo is private. PyPI cannot resolve them, so its
81
+ page shows their alt text instead -- and an absolute URL would not fix
82
+ that, because it 404s for PyPI's readers exactly as it does for GitHub's.
83
+ docs/assets/ is also deliberately excluded from both the wheel and the
84
+ sdist (tests/test_packaging.py builds the real artifacts and asserts it).
85
+
86
+ ON PUBLISH, and only once the repo is PUBLIC, absolute raw.githubusercontent
87
+ URLs become correct and are then required for PyPI rendering. Public first;
88
+ flipping these while the repo is private just breaks GitHub again.
89
+ tests/test_readme_renders_on_github.py enforces the relative form and that
90
+ every relative target exists. -->
91
+
92
+ # <img src="docs/assets/flywheel-spinner.gif" height="44" alt="" align="middle"> Agent Flywheel
93
+
94
+ Built and Maintained by **[PerceptEye](https://percepteye.ai)**
95
+
96
+ ### **Your agent gets better at its own job. You don't run any of the training.**
97
+
98
+ Ship one line. Your agent keeps running in your process, behind your firewall, on your credentials — **unchanged** — and the model it calls keeps improving.
99
+
100
+ <!-- BADGES — `ci` was a live shields.io badge and rendered as a red error
101
+ image, because shields.io queries GitHub anonymously and this repo is
102
+ PRIVATE, so GitHub's API 404s for an anonymous caller whatever
103
+ .github/workflows/ci.yml — which does exist, and is pushed — actually did.
104
+ Verified against the live service 2026-09-06.
105
+
106
+ ONCE THE REPO IS PUBLIC restore it verbatim. Do not hand-write a
107
+ "passing" into a static badge to fill the gap — that asserts a state
108
+ nothing checks.
109
+ [![ci](https://img.shields.io/github/actions/workflow/status/percepteye-ai/agent-flywheel/ci.yml?branch=main&label=ci&style=for-the-badge&logo=githubactions&logoColor=white&color=22c55e)](https://github.com/percepteye-ai/agent-flywheel/actions)
110
+ `pypi` is live and asks PyPI for the published version of
111
+ `agent-flywheel`. PyPI answers anonymous callers, so unlike `ci` it only
112
+ reads "not found" until the first release is out. This README is also
113
+ that release's PyPI page, so it must not say "not published" anywhere.
114
+ `ci` gets no static stand-in: the hardcoded `tests` badge below already
115
+ carries what a reader wanted from it, and a README cannot assert its own
116
+ pipeline state. That badge used to be hardcoded with nothing checking it,
117
+ and it drifted: it read 695 while the suite was 749, and the Development
118
+ section below said 695 while the docs site said 509. It is CHECKED now.
119
+ tests/test_documented_counts_are_true.py re-derives the suite size and
120
+ requires every published copy to match it, here and on the docs site, so
121
+ editing one copy alone turns the suite red. Re-derive by hand with:
122
+ .venv/bin/python -m pytest tests/ --collect-only -q -p no:warnings \
123
+ | awk -F': ' '/^tests\/.*: [0-9]+$/{n+=$2} END{print n}' # -> 982 -->
124
+ [![pypi](https://img.shields.io/pypi/v/agent-flywheel?style=for-the-badge&logo=pypi&logoColor=white&label=pypi&color=3b82f6)](https://pypi.org/project/agent-flywheel/)
125
+ [![python](https://img.shields.io/badge/python-3.10%20→%203.13-3b82f6?style=for-the-badge&logo=python&logoColor=white)](pyproject.toml)
126
+ <br/>
127
+ [![runtime dependencies](https://img.shields.io/badge/runtime_deps-1-f59e0b?style=for-the-badge)](pyproject.toml)
128
+ [![lines changed in your agent](https://img.shields.io/badge/lines_changed_in_your_agent-0-ec4899?style=for-the-badge)](#-zero-instrumentation-concretely)
129
+ [![tests](https://img.shields.io/badge/tests-982_·_0_skips-22c55e?style=for-the-badge)](#-development)
130
+ [![license](https://img.shields.io/badge/license-MIT-14b8a6?style=for-the-badge)](LICENSE)
131
+
132
+ <img src="docs/assets/flywheel-overview.gif" width="1040"
133
+ alt="Your agent — prompt, tools and model — sits inside your infrastructure. fw.serve() wraps it from the outside with zero lines changed inside. Rollouts and observed outcomes go out; a trained policy comes back. The model row flips from your-model to a policy trained on this agent's own work, while the prompt and tools rows stay untouched.">
134
+
135
+ **[Start in one call](#-start-in-one-call)** · [Harnesses](#-harnesses-supported) · [How the loop closes](#-how-the-loop-closes) · [Training vs production](#-two-modes-one-image) · [Production capture](#-production-observability) · [Docs](website/docs/)
136
+
137
+ </div>
138
+
139
+ ---
140
+
141
+ ## <img src="docs/assets/flywheel-spinner.gif" height="26" alt="" align="middle"> A continual-learning loop around the agent you already have
142
+
143
+ Your agent already does real work. **This turns that work into training data, and delivers what it learns back as an endpoint your agent already calls.**
144
+
145
+ That is the whole product. Not a dashboard you read, not a benchmark you run — a loop that closes on its own and leaves your agent measurably better than the one you deployed.
146
+
147
+ | you get | what it actually means |
148
+ |:--|:--|
149
+ | 📈 **An agent that improves in place** | a policy trained on *this* agent's own trajectories — its prompt, its tools, its failure modes — not a generic model |
150
+ | 🤖 **Post-training you never operate** | task generation, grading, reinforcement learning, promotion gates: run by the control plane, on our GPUs |
151
+ | 🧠 **A prompt that improves too** | training co-optimizes the system prompt and ships the approved one back beside the model |
152
+ | 🔭 **Honest evidence** | every tool call is recorded as `ok`, `failed`, or `unknown` — the third is legal, expected, and never rounded up to `ok`, so a verifier abstains instead of learning from a lie |
153
+ | 🔒 **Nothing moves** | no container, no cluster, no GPU, no inbound port; every connection is dialled out from your side |
154
+
155
+ **And you can watch every lap.** The control plane is the part you don't build — not a part you can't see. Sign in and the tasks it generated, how each rollout scored, and what got promoted are all there to inspect.
156
+
157
+ ## ⚡ Start in one call
158
+
159
+ ```bash
160
+ pip install agent-flywheel # one runtime dependency: httpx
161
+ ```
162
+
163
+ ```python
164
+ import agent_flywheel as fw
165
+
166
+ fw.serve("my_package.agent:build", agent_id="my-coding-agent", input_shape="text")
167
+ ```
168
+
169
+ **That is the integration.** You pass an importable `"module:attr"` factory so each rollout can rebuild your agent in a fresh child process. `serve()` claims rollouts, runs them, reports outcomes, and blocks until you stop it.
170
+
171
+ It needs two things from the environment — there is no baked-in address:
172
+
173
+ ```bash
174
+ export PERCEPTEYE_CONTROL_PLANE_URL=https://launch.percepteye.ai/api/flywheel/v1
175
+ export PERCEPTEYE_API_KEY=pefw_... # issued by whoever operates your control plane
176
+ ```
177
+
178
+ Either one missing and `serve()` raises before the first rollout, naming it.
179
+
180
+ Everything that can be misconfigured — entrypoint, credential, contract version, isolation mode — is checked **before the first rollout** and raises there, never mid-run.
181
+
182
+ <!-- SIGNUP ROUTE STILL NEEDED — owner action. There is no self-service signup,
183
+ no key-request form and no "request access" endpoint anywhere in either SDK
184
+ repo; grep for one before assuming otherwise. support@percepteye.ai below is
185
+ the only non-security address either repo contains (the sibling Node README
186
+ is its single occurrence — it is uncorroborated by any code, config, DNS or
187
+ docs page here), and it is used because the Node README already gives it and
188
+ both SDKs must answer this question identically. Replace this paragraph with
189
+ the real route once it exists, in BOTH READMEs. Do not invent one, and do
190
+ not link a host that has not been resolved: docs.percepteye.ai was linked
191
+ here and does not resolve (checked 2026-09-06). -->
192
+
193
+ **Where a `pefw_` key comes from:** it is issued by whoever operates your PerceptEye control plane — ask them, or write to **support@percepteye.ai**. There is no self-service page to sign up on, and this README deliberately does not point you at one.
194
+
195
+ > [!TIP]
196
+ > Rollouts are real runs on your real credentials. Bound the first one with `max_rollouts=8` and point `artifacts_dir=` somewhere you don't mind it writing. `max_rollouts` is also the only clean exit: there is no signal handler, and Ctrl-C drains in-flight rollouts first.
197
+
198
+ ### Your agent is a binary? Pass its command line.
199
+
200
+ Any CLI agent is an argv list — no protocol to implement, no change to the binary:
201
+
202
+ ```python
203
+ fw.serve(["my-agent", "run"], agent_id="my-cli-agent", input_shape="text")
204
+ ```
205
+
206
+ The task arrives on **stdin** and as `PERCEPTEYE_TURN_INPUT`; ordinary **stdout** is the final text. Structured JSONL is interpreted only when you pass an explicit `command_output_adapter`, so core never guesses a customer's event vocabulary. Mark an argv position with `fw.TASK` if your CLI takes the task as an argument.
207
+
208
+ ## 🧩 Harnesses supported
209
+
210
+ | harness | surface | integration |
211
+ |:--|:--|:--|
212
+ | **LangChain** · **LangGraph** · **Google ADK** | 🐍 Python | adapter auto-binds — **zero code in your agent** |
213
+ | **any CLI agent** | ⌘ CLI | `fw.serve(["my-agent", "run"], ...)` — nothing added to the binary |
214
+ | **any structured CLI** | ⌘ CLI | provide a `CommandOutputAdapter`; the generic `StreamFormat` parser can reconcile tool lifecycles |
215
+ | **prime-agent** *(optional example)* | ⌘ CLI | argv + explicit `PrimeAgentOutputAdapter` — **[worked example →](examples/prime_agent/)** |
216
+
217
+ Adapters are on by default (`adapters="auto"`) and bind every built-in whose framework is installed. No adapter imports its framework until you bind it, so they add **zero dependencies**. Shipping your own is one entry-point factory in the `agent_flywheel.adapters` group.
218
+
219
+ The command adapter is explicit and optional. Capture from it survives clean exits, non-zero exits and timeout kills — a rollout that died is exactly the one you need the tool calls from. With no adapter, structured stdout remains ordinary stdout and makes no telemetry or model-call-count claim.
220
+
221
+ *Running OpenClaw or DeepSeek Harness?* Those are the Node plugin → [`percepteye-ai/agent-flywheel-node`](https://github.com/percepteye-ai/agent-flywheel-node).
222
+
223
+ ## 🔁 How the loop closes
224
+
225
+ <p align="center">
226
+ <img src="docs/assets/flywheel-loop.gif" width="980"
227
+ alt="The SDK integrating into a customer's agent and talking to the control plane. On the left, inside your infrastructure: pip install, the harness, your agent unchanged, and its trajectory. A dashed trust boundary runs down the middle. On the right, the control plane generates tasks from your tool surface, scores every rollout, trains the policy, and hands a candidate back as an endpoint you can call.">
228
+ </p>
229
+
230
+ **Register once, then loop forever.** Every lane crosses the trust boundary **outbound, opened from your side**.
231
+
232
+ | beat | what happens | who does the work |
233
+ |:--|:--|:--|
234
+ | **⓪ Onboard** | the SDK describes your agent from inside its own process and registers it — once, before the first rollout | automatic; `introspect=False` opts out |
235
+ | **① Claim** | your process long-polls for a rollout; leases heartbeat, crashed workers redeliver | you dial out |
236
+ | **② Run** | fresh child process, own environment, single-use credential, own trajectory | your agent, unchanged |
237
+ | **③ Record** | every tool call lands as `ok` / `failed` / `unknown`; artifacts collected on every path | adapters, from the outside |
238
+ | **④ Score & train** | tasks generated from your real tool surface, graded against compiled checks, then trained | **PerceptEye — you build none of it** |
239
+ | **⑤ Improve** | your production agent picks up the promoted policy; the next lap runs on the better agent | one call at startup |
240
+
241
+ That last beat is what makes it a flywheel rather than a pipeline: **the better agent generates the better evidence.**
242
+
243
+ Beat ⓪ is why you never write a task list. Tasks come from **the tool surface you actually have** — read after templating and conditional registration, so it is the prompt your agent really assembled, the tools it really registered, the sub-agents it really built. A CLI agent with no object to read is warmed up once, well under a second, and described from that.
244
+
245
+ ## 🎚 Two modes, one image
246
+
247
+ One variable decides whether a deployment does training work. The same container does either, with no code edit.
248
+
249
+ ```bash
250
+ PERCEPTEYE_AGENT_MODE=training # the default when unset
251
+ PERCEPTEYE_AGENT_MODE=production
252
+ ```
253
+
254
+ | | `training` | `production` |
255
+ |:--|:--|:--|
256
+ | **pick it for** | a machine you're happy to lend to rollouts | the deployment serving your users |
257
+ | `fw.serve(...)` | claims and runs rollouts | returns `0` immediately, **before opening any connection** |
258
+ | needs a URL and key | yes | no |
259
+
260
+ An unrecognised value **raises** rather than guessing: a typo that silently picked `training` would keep spending your credentials with nothing printed. (`train`, `rollout`, `fine-tuning` also mean contribute; `prod` and `serving` mean don't. `mode=` in code beats the variable, and the printed line says when that happened.)
261
+
262
+ > [!IMPORTANT]
263
+ > The switch gates **`serve()`** — the call that does training work. It does not gate `attach()` — that *is* the production surface. Gating both would mean the correct setting for a production agent was `training`.
264
+
265
+ **Contribute nothing, still get the trained model.** No `serve()`, no `attach()`, no registration:
266
+
267
+ ```python
268
+ kw = fw.apply_current_policy(agent_id="my-agent")
269
+ if kw: # {} means keep your current config
270
+ client = OpenAI(base_url=kw["base_url"], api_key=kw["api_key"])
271
+ model = kw["model"]
272
+
273
+ served = fw.current_prompt(agent_id="my-agent") # the approved prompt
274
+ decision = fw.prompt_decision(served) # may I apply it?
275
+ if decision.apply:
276
+ system_prompt = decision.text
277
+ else:
278
+ log.info("keeping the current prompt: %s", decision.reason)
279
+
280
+ src = fw.policy_source(agent_id="my-agent", on_change=reconfigure)
281
+ snap = src.current() # per turn; between refreshes it's a cache read
282
+ ```
283
+
284
+ **Nothing polls in the background.** The refresh runs inside your call — no timer, no poller — so a promotion *or a rollback* reaches production with no redeploy. After its first success `policy_source()` keeps serving the last good policy, so a network blip can never silently demote production back to the untrained model.
285
+
286
+ `prompt_decision()` is the whole rule in one call, and the SDK never applies a prompt for you: it checks that a prompt is actually being served, that your own baseline prompt has not changed since the pair was certified, that the bundle's status is one a person approved, and that the prompt's identity matches the checkpoint it was certified against. `apply` is false unless all four hold, `reason` says which one failed, and `text` is `None` rather than `""` — so a refusal can never blank the instructions your agent is already running on. The Node SDK asks the identical question, and [`tests/test_prompt_decision_parity.py`](tests/test_prompt_decision_parity.py) drives one table of cases through both implementations and asserts every field of the answer agrees.
287
+
288
+ ## ✋ Nothing reaches your users until a person approves it
289
+
290
+ **Both halves of an improvement — the model and the prompt — require a human decision.** Not a threshold, not a scheduler, not a language model reading a report and deciding it looks good.
291
+
292
+ **The model.** Your agent keeps serving the model it is on today — the **champion** — until *all three* of these are true, and every one of them fails closed back to it:
293
+
294
+ 1. **A person with the operator role moves that agent onto the candidate**, from the dashboard. It is an explicit, role-gated call on that one agent. No promotion applies it, no percentage rollout applies it, and there is no automatic path to it — moving *back* is the same call with the other value, which is what makes rollback fast enough to use during an incident.
295
+ 2. **The candidate's certification did not go against it.** A verdict of *regressed*, *inconclusive* or *escalated* makes it unservable — the same set the promotion gate itself refuses on, so a candidate your agent could be moved onto is one that gate would also have accepted.
296
+ 3. **It is genuinely deployed somewhere your agent can call.** An address that is a stored artifact rather than a running endpoint — a bucket or file path — is refused, so an approved candidate that nobody has served yet keeps the champion in place instead of pointing your agent at something that answers nothing. **The SDK re-checks this itself** rather than trusting the answer: `endpoint_kwargs()` — the one place a served policy becomes client arguments, and what both `apply_current_policy()` and `policy_source()` go through — returns `{}` and logs why, on the same prefix set the Node SDK refuses on. [`tests/test_served_model_is_servable.py`](tests/test_served_model_is_servable.py) drives both implementations and asserts the two agree.
297
+
298
+ **The prompt** is a separate chain with its own approval: the control plane serves a prompt only from a delivery a person approved, and `prompt_decision()` above re-checks that status on your side before anything is applied.
299
+
300
+ **And no language model can grant either approval.** Promoting a model, delivering a prompt pair, and promoting a research result are all marked irreversible in the approval system: only the explicit approve or reject control can resolve them. A model reading free-form text and interpreting "sure, go ahead" as consent is recorded as *answered* and never as *approved*, so it cannot promote, deliver, or roll anything out.
301
+
302
+ ## 🔭 Production observability
303
+
304
+ `attach()` records the turns you are **already serving** — same adapters, same tri-state outcomes, same trajectory format as a rollout.
305
+
306
+ ```python
307
+ px = fw.attach(agent_id="support-agent", trajectory_root="/var/lib/percepteye")
308
+
309
+ with px.turn(task_id=case_id, conversation_id=conv_id,
310
+ input_text=user_text) as turn_id:
311
+ reply = agent.invoke(user_text) # your agent, unmodified
312
+
313
+ px.record_answer(turn_id, reply) # after the scope closes — the answer exists now
314
+ ```
315
+
316
+ **It captures. It never reroutes.** Adapters bind with no endpoint at all, so attaching cannot re-point a single byte of production traffic. Everything fails open: an unreachable control plane costs you the observation and nothing else.
317
+
318
+ For identity-bound production analysis, pass the same live Python agent as
319
+ `execution_entrypoint=` together with adapter-authored
320
+ `attested_execution_components`. The SDK emits
321
+ `agent_fingerprint.execution_sha256` only when its prompt/tools/model/sub-agent
322
+ discovery is exact and the opaque component evidence is valid; otherwise the
323
+ field stays absent. It is persisted per turn and projected to OTel as
324
+ `percepteye.agent.execution_sha256`. `attach()` does not claim a policy identity:
325
+ the policy it reads is informational and does not prove what your application ran.
326
+
327
+ `task_id=` is optional, opaque correlation evidence for your own case, job, or
328
+ work item. The SDK persists it, includes it in the turn upload, and projects it
329
+ as `percepteye.task.id`; it does not interpret the value or generate advice.
330
+ Supplying it grants no training eligibility. Control-plane clustering and
331
+ dataset services validate any later trace-to-task binding.
332
+
333
+ > [!IMPORTANT]
334
+ > **Upload is opt-in per agent and off by default.** A production turn is your end user's message and the answer you gave them, so nothing is sent until an operator enables capture for that agent. Until then turns accumulate as trajectory directories under the `trajectory_root` you named and go nowhere.
335
+ >
336
+ > **No restart needed to enable it.** While the answer is no, the SDK re-reads the verdict on its flush cadence and starts uploading as soon as an operator says yes. `px.may_upload` is the live answer; `px.upload_refusal` is the sentence explaining a `False`.
337
+
338
+ `input_text=` and `record_answer()` are the two lines that carry text, and both are yours to pass — adapters observe tool calls, not turn content. Once capture is on, uploading is automatic: answered turns go every 32 turns on a short-lived daemon thread, and an `atexit` hook sends the rest. Uploads never raise into your process and never block a turn on the network.
339
+
340
+ Production turns are **graded advisory, always** — they were produced by whatever you run, so they inform training rather than being scored as rollouts are. Training capacity still comes from `serve()`.
341
+
342
+ ## 🧪 Zero instrumentation, concretely
343
+
344
+ This LangChain agent contains no reference to Agent Flywheel of any kind:
345
+
346
+ ```python
347
+ # lc_agent.py — your existing agent, untouched
348
+ from langchain_core.tools import tool
349
+
350
+ @tool
351
+ def lookup(q: str) -> str:
352
+ """Look something up."""
353
+ return f"found: {q}"
354
+
355
+ class Agent:
356
+ tools = [lookup]
357
+ def invoke(self, task: str) -> str:
358
+ return str(lookup.invoke({"q": task}))
359
+
360
+ def build():
361
+ return Agent()
362
+ ```
363
+
364
+ Served with the two-line `fw.serve()` integration from [Start in one call](#-start-in-one-call), the rollout reports:
365
+
366
+ ```json
367
+ "tool_calls": [{"name": "lookup", "arguments": {"q": "count to three"},
368
+ "outcome": "unknown", "status_code": null,
369
+ "output": "found: count to three", "latency_ms": 0.199}]
370
+ ```
371
+
372
+ **Not one `agent_flywheel` import produced that record**, and it is enforced by [`tests/test_zero_instrumentation.py`](tests/test_zero_instrumentation.py). One `record_tool_call` where you *do* know the answer gets you the rest:
373
+
374
+ ```python
375
+ from agent_flywheel import record_tool_call
376
+
377
+ record_tool_call("charge_card", {"amount": 4200}, outcome="ok", status_code=201)
378
+ ```
379
+
380
+ ## ⚙️ Configuration
381
+
382
+ | variable | meaning |
383
+ |:--|:--|
384
+ | `PERCEPTEYE_CONTROL_PLANE_URL` | control-plane base URL. **No default** — unset means nothing is claimed and no trained policy is read, and every call that needed it now says so |
385
+ | `PERCEPTEYE_API_KEY` | your flywheel key |
386
+ | `PERCEPTEYE_AGENT_MODE` | `training` (default) or `production` — [one switch, both ways](#-two-modes-one-image) |
387
+ | `PERCEPTEYE_INTROSPECT` | `0` disables agent discovery |
388
+ | `PERCEPTEYE_TRAJECTORY_DIR` | one explicit trajectory directory, for a single run |
389
+
390
+ Two of them change behaviour; the rest are an address, a credential and a location, and the first two are overridable in code as `control_plane_url=` and `api_key=`. `serve()` also takes `isolation` (`"subprocess"` is the default and the only mode with a real timeout), `concurrency`, `method`, `rollout_timeout_s`, `execution_snapshot` (a generic adapter-owned prepare/attest/release lifecycle), `attested_execution_components` (opaque adapter-owned SHA-256 evidence for exact OPSD execution identity), and `on_event` for a synchronous event callback — `claim_empty` versus `claim_failed` is how you tell an empty queue from a broken route.
391
+
392
+ **Your agent must sample through an OpenAI-compatible client**, because the per-rollout gateway speaks that API. Provider variables we cannot point at it are stripped from the child, and `serve()` warns at startup if it finds any.
393
+
394
+ <details>
395
+ <summary><b>Agent discovery</b> — what is sent, and how to turn it off</summary>
396
+
397
+ <br/>
398
+
399
+ Once at startup the SDK reads the *shape* of the agent you handed `serve()`: system prompt (the registration projection is truncated at 20k, while an available full observation is hashed before projection), tool names/descriptions/JSON Schemas, provider and model name, sub-agents to depth 4, and the framework matched. **Never sent:** your tools' source or return values, your credentials, your data, or anything observed during a rollout. Turn it off with `fw.serve(..., introspect=False)` or `PERCEPTEYE_INTROSPECT=0` — and only a deliberate value (`0`, `false`, `no`, `off`) disables it, so a typo leaves it on. Disabled, unreadable, or lossy discovery produces no affirmative `agent_fingerprint.execution_sha256`, so an OPSD consumer fails closed.
400
+ </details>
401
+
402
+ <details>
403
+ <summary><b>OpenTelemetry</b> — optional, egress only</summary>
404
+
405
+ <br/>
406
+
407
+ `pip install "agent-flywheel[otel]"` and every outcome is additionally emitted as a span, so it lands in whatever you already run. The extra is the **API only, never the SDK**: this package emits into your registered `TracerProvider` and never installs one. Spans carry the GenAI semantic conventions plus `percepteye.tool.outcome`, and an `unknown` leaves the span status unset.
408
+ </details>
409
+
410
+ ## 🛠 Development
411
+
412
+ ```bash
413
+ python -m venv .venv
414
+ .venv/bin/pip install -e ".[dev,adapters-test,otel,otel-test]"
415
+ .venv/bin/pytest
416
+ ./scripts/cross_language_abi_check.sh # the cross-language contract, both ways
417
+ ```
418
+
419
+ The last one is **not** the Node package's own suite — that lives in the [Node repository](https://github.com/percepteye-ai/agent-flywheel-node) and runs there with `npm test`. This drives records through the Node writer and asserts the Python `Trajectory` reads them back with the same meaning, which is the half only this repo can check. **It needs a Node checkout**: point `PERCEPTEYE_NODE_SDK` at one, or keep it as a sibling directory. Without one it prints `SKIPPED` and exits 0, and the cross-language tests in the suite above skip with it.
420
+
421
+ 982 tests, and CI permits **zero skips** — a skipped test and a passing test look identical in a green check mark. CI names `PERCEPTEYE_NODE_SDK`, so the cross-language half cannot go green by skipping there. Other jobs assert that a bare install still works on Python 3.10→3.13, that the runtime dependency list never grows past `httpx`, and that the Node plugins never drift from the Python reader.
422
+
423
+ [`schema/flywheel-1.json`](schema/flywheel-1.json) is the wire contract and this repository owns it. Absence is never collapsed into zero: `tool_calls=None` means "I did not report tool calls"; `[]` means "I observed zero", a positive claim. The animations above are generated, not drawn — [`docs/assets/render.sh`](docs/assets/render.sh) rebuilds them.
424
+
425
+ ---
426
+
427
+ **Contributing** — issues and PRs welcome; a new adapter must not import its framework at module scope, must report `unknown` rather than guess, and must fail loudly at bind time. ·
428
+ **Security** — please report vulnerabilities privately to **security@percepteye.ai**. ·
429
+ **License** — [MIT](LICENSE) © PerceptEye.