adk-perseus-context 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.
- adk_perseus_context-0.1.0/.github/workflows/publish.yml +63 -0
- adk_perseus_context-0.1.0/.gitignore +7 -0
- adk_perseus_context-0.1.0/LICENSE +21 -0
- adk_perseus_context-0.1.0/PKG-INFO +167 -0
- adk_perseus_context-0.1.0/README.md +140 -0
- adk_perseus_context-0.1.0/adk_perseus_context/__init__.py +28 -0
- adk_perseus_context-0.1.0/adk_perseus_context/_resolve.py +73 -0
- adk_perseus_context-0.1.0/adk_perseus_context/callback.py +62 -0
- adk_perseus_context-0.1.0/adk_perseus_context/plugin.py +78 -0
- adk_perseus_context-0.1.0/examples/context.perseus +5 -0
- adk_perseus_context-0.1.0/examples/runner_plugin_example.py +75 -0
- adk_perseus_context-0.1.0/pyproject.toml +40 -0
- adk_perseus_context-0.1.0/tests/test_plugin.py +136 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: actions/setup-python@v5
|
|
15
|
+
with:
|
|
16
|
+
python-version: '3.12'
|
|
17
|
+
- name: Install
|
|
18
|
+
# Tests stub the `perseus` module, so the runtime perseus-ctx dependency
|
|
19
|
+
# is installed --no-deps-style: only google-adk + the test tooling are
|
|
20
|
+
# needed to run the suite.
|
|
21
|
+
run: |
|
|
22
|
+
pip install google-adk pytest pytest-asyncio
|
|
23
|
+
pip install -e . --no-deps
|
|
24
|
+
- name: Test
|
|
25
|
+
run: pytest -q
|
|
26
|
+
|
|
27
|
+
build:
|
|
28
|
+
needs: test
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
steps:
|
|
31
|
+
- uses: actions/checkout@v4
|
|
32
|
+
|
|
33
|
+
- uses: actions/setup-python@v5
|
|
34
|
+
with:
|
|
35
|
+
python-version: '3.12'
|
|
36
|
+
|
|
37
|
+
- name: Install build
|
|
38
|
+
run: pip install build==1.2.2.post1
|
|
39
|
+
|
|
40
|
+
- name: Build
|
|
41
|
+
run: python -m build
|
|
42
|
+
|
|
43
|
+
- uses: actions/upload-artifact@v4
|
|
44
|
+
with:
|
|
45
|
+
name: dist
|
|
46
|
+
path: dist/
|
|
47
|
+
|
|
48
|
+
publish:
|
|
49
|
+
needs: build
|
|
50
|
+
runs-on: ubuntu-latest
|
|
51
|
+
environment:
|
|
52
|
+
name: pypi
|
|
53
|
+
url: https://pypi.org/p/adk-perseus-context
|
|
54
|
+
permissions:
|
|
55
|
+
id-token: write
|
|
56
|
+
steps:
|
|
57
|
+
- uses: actions/download-artifact@v4
|
|
58
|
+
with:
|
|
59
|
+
name: dist
|
|
60
|
+
path: dist/
|
|
61
|
+
|
|
62
|
+
- name: Publish to PyPI
|
|
63
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Perseus Computing
|
|
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,167 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: adk-perseus-context
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Deterministic live context for Google ADK agents — compiled by Perseus, injected as system instruction.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Perseus-Computing-LLC/adk-perseus-context
|
|
6
|
+
Project-URL: Repository, https://github.com/Perseus-Computing-LLC/adk-perseus-context
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/Perseus-Computing-LLC/adk-perseus-context/issues
|
|
8
|
+
Author-email: Thomas Connally <51974392+tcconnally@users.noreply.github.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: google-adk>=1.0.0
|
|
22
|
+
Requires-Dist: perseus-ctx>=1.0.10
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# ADK Perseus Context
|
|
29
|
+
|
|
30
|
+
Deterministic live context for [Google ADK](https://github.com/google/adk-python)
|
|
31
|
+
agents — compiled by [Perseus](https://github.com/Perseus-Computing-LLC/perseus)
|
|
32
|
+
and injected straight into your agent's system instruction.
|
|
33
|
+
|
|
34
|
+
Perseus is an open-source (MIT) **context compiler**. It resolves directives like
|
|
35
|
+
`@file`, `@search`, and `@memory` into one deterministic, byte-stable context
|
|
36
|
+
string at inference time — **no retrieval index, no embeddings, no LLM
|
|
37
|
+
round-trip.** This package wires that compiler into ADK as a first-class
|
|
38
|
+
extension point.
|
|
39
|
+
|
|
40
|
+
> **Perseus is not memory or RAG.** It *assembles* context deterministically.
|
|
41
|
+
> For persistent cross-session agent memory, see the companion package
|
|
42
|
+
> [`adk-mimir-memory`](https://github.com/Perseus-Computing-LLC/adk-mimir-memory)
|
|
43
|
+
> — Perseus and Mimir compose ("own your context" + "own your memory").
|
|
44
|
+
|
|
45
|
+
## Why a context compiler?
|
|
46
|
+
|
|
47
|
+
| Approach | Index / embeddings | LLM round-trip | Output stability | Coverage |
|
|
48
|
+
|---|---|---|---|---|
|
|
49
|
+
| Naive "dump everything" | ❌ | ❌ | Stable | Full, but bloated |
|
|
50
|
+
| RAG / vector retrieval | ✅ required | sometimes | Varies per query | Top-k (can miss facts) |
|
|
51
|
+
| **Perseus compile** | ❌ none | ❌ none | **Byte-identical** | **Full, deterministic** |
|
|
52
|
+
|
|
53
|
+
The edge is **determinism + full coverage at a fixed compiled size** — the same
|
|
54
|
+
inputs always produce the same context, with no retrieval tax. Less-but-better
|
|
55
|
+
context is also a measurable *quality* win, not just a cost one.
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install adk-perseus-context
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Requires Python 3.10+, `google-adk>=1.0.0`, and `perseus-ctx>=1.0.10` (the
|
|
64
|
+
Context Adapter SDK). Both are pulled in automatically.
|
|
65
|
+
|
|
66
|
+
## Quick start
|
|
67
|
+
|
|
68
|
+
### Runner-wide (plugin)
|
|
69
|
+
|
|
70
|
+
Inject one context across **every** agent driven by a `Runner`:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from google.adk.agents import Agent
|
|
74
|
+
from google.adk.runners import Runner
|
|
75
|
+
from google.adk.sessions import InMemorySessionService
|
|
76
|
+
from adk_perseus_context import PerseusContextPlugin
|
|
77
|
+
|
|
78
|
+
agent = Agent(name="assistant", model="gemini-flash-latest", instruction="Help the user.")
|
|
79
|
+
|
|
80
|
+
runner = Runner(
|
|
81
|
+
agent=agent,
|
|
82
|
+
app_name="my_app",
|
|
83
|
+
session_service=InMemorySessionService(),
|
|
84
|
+
plugins=[PerseusContextPlugin("context.perseus")], # file path or inline @perseus source
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Single agent (callback)
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from google.adk.agents import Agent
|
|
92
|
+
from adk_perseus_context import perseus_before_model_callback
|
|
93
|
+
|
|
94
|
+
agent = Agent(
|
|
95
|
+
name="assistant",
|
|
96
|
+
model="gemini-flash-latest",
|
|
97
|
+
instruction="Help the user.",
|
|
98
|
+
before_model_callback=perseus_before_model_callback("context.perseus"),
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Either way, the compiled Perseus context is appended to the request's system
|
|
103
|
+
instruction (via ADK's `LlmRequest.append_instructions`) on every model call.
|
|
104
|
+
|
|
105
|
+
## Per-session context
|
|
106
|
+
|
|
107
|
+
Override the source per session through session state — useful when each user or
|
|
108
|
+
task needs a different workspace or directive set:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
session = await runner.session_service.create_session(
|
|
112
|
+
app_name="my_app",
|
|
113
|
+
user_id="user",
|
|
114
|
+
state={
|
|
115
|
+
"_perseus_source": "@perseus\n@file AGENTS.md\n@memory deployment",
|
|
116
|
+
"_perseus_workspace": "/path/to/project",
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
State keys are exported as `adk_perseus_context.STATE_SOURCE` and
|
|
122
|
+
`STATE_WORKSPACE`. A per-session source takes precedence over the static one.
|
|
123
|
+
|
|
124
|
+
## Inline vs. file sources
|
|
125
|
+
|
|
126
|
+
`source` is either a path to a `.perseus` file or an inline source string that
|
|
127
|
+
starts with `@perseus`:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
PerseusContextPlugin("@perseus\n\nYou are a concise assistant. @file README.md")
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
For a file source, the workspace defaults to the file's directory so relative
|
|
134
|
+
`@include` / `@file` paths resolve.
|
|
135
|
+
|
|
136
|
+
## Fail-open by default
|
|
137
|
+
|
|
138
|
+
If Perseus is missing or a compile raises, the request proceeds **without**
|
|
139
|
+
injected context and a warning is logged, so a context problem never takes your
|
|
140
|
+
agent down. Pass `fail_open=False` to make such errors propagate instead.
|
|
141
|
+
|
|
142
|
+
## How it works
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
before_model_callback
|
|
146
|
+
┌─────────────┐ ┌─────────────────────────┐ ┌──────────────┐
|
|
147
|
+
│ ADK Runner │ ──▶ │ PerseusContextPlugin │ ──▶ │ LlmRequest │
|
|
148
|
+
│ / Agent │ │ perseus.compile_context │ │ system_ │
|
|
149
|
+
└─────────────┘ │ (deterministic, local) │ │ instruction │
|
|
150
|
+
└─────────────────────────┘ └──────────────┘
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The plugin/callback calls `perseus.compile_context(source)` — Perseus's Context
|
|
154
|
+
Adapter SDK "resolve once" primitive — and appends the result to the system
|
|
155
|
+
instruction. Perseus owns deterministic assembly; ADK owns orchestration.
|
|
156
|
+
|
|
157
|
+
## Compose with Mimir
|
|
158
|
+
|
|
159
|
+
Perseus and Mimir are designed to compose: Mimir provides persistent, encrypted
|
|
160
|
+
memory; Perseus pulls hot memory into a compiled context via `@memory`
|
|
161
|
+
directives. Use `adk-mimir-memory` for the memory backend and this package for
|
|
162
|
+
the context layer.
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
MIT — see [Perseus](https://github.com/Perseus-Computing-LLC/perseus) for the
|
|
167
|
+
backing context engine.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# ADK Perseus Context
|
|
2
|
+
|
|
3
|
+
Deterministic live context for [Google ADK](https://github.com/google/adk-python)
|
|
4
|
+
agents — compiled by [Perseus](https://github.com/Perseus-Computing-LLC/perseus)
|
|
5
|
+
and injected straight into your agent's system instruction.
|
|
6
|
+
|
|
7
|
+
Perseus is an open-source (MIT) **context compiler**. It resolves directives like
|
|
8
|
+
`@file`, `@search`, and `@memory` into one deterministic, byte-stable context
|
|
9
|
+
string at inference time — **no retrieval index, no embeddings, no LLM
|
|
10
|
+
round-trip.** This package wires that compiler into ADK as a first-class
|
|
11
|
+
extension point.
|
|
12
|
+
|
|
13
|
+
> **Perseus is not memory or RAG.** It *assembles* context deterministically.
|
|
14
|
+
> For persistent cross-session agent memory, see the companion package
|
|
15
|
+
> [`adk-mimir-memory`](https://github.com/Perseus-Computing-LLC/adk-mimir-memory)
|
|
16
|
+
> — Perseus and Mimir compose ("own your context" + "own your memory").
|
|
17
|
+
|
|
18
|
+
## Why a context compiler?
|
|
19
|
+
|
|
20
|
+
| Approach | Index / embeddings | LLM round-trip | Output stability | Coverage |
|
|
21
|
+
|---|---|---|---|---|
|
|
22
|
+
| Naive "dump everything" | ❌ | ❌ | Stable | Full, but bloated |
|
|
23
|
+
| RAG / vector retrieval | ✅ required | sometimes | Varies per query | Top-k (can miss facts) |
|
|
24
|
+
| **Perseus compile** | ❌ none | ❌ none | **Byte-identical** | **Full, deterministic** |
|
|
25
|
+
|
|
26
|
+
The edge is **determinism + full coverage at a fixed compiled size** — the same
|
|
27
|
+
inputs always produce the same context, with no retrieval tax. Less-but-better
|
|
28
|
+
context is also a measurable *quality* win, not just a cost one.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install adk-perseus-context
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Requires Python 3.10+, `google-adk>=1.0.0`, and `perseus-ctx>=1.0.10` (the
|
|
37
|
+
Context Adapter SDK). Both are pulled in automatically.
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
### Runner-wide (plugin)
|
|
42
|
+
|
|
43
|
+
Inject one context across **every** agent driven by a `Runner`:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from google.adk.agents import Agent
|
|
47
|
+
from google.adk.runners import Runner
|
|
48
|
+
from google.adk.sessions import InMemorySessionService
|
|
49
|
+
from adk_perseus_context import PerseusContextPlugin
|
|
50
|
+
|
|
51
|
+
agent = Agent(name="assistant", model="gemini-flash-latest", instruction="Help the user.")
|
|
52
|
+
|
|
53
|
+
runner = Runner(
|
|
54
|
+
agent=agent,
|
|
55
|
+
app_name="my_app",
|
|
56
|
+
session_service=InMemorySessionService(),
|
|
57
|
+
plugins=[PerseusContextPlugin("context.perseus")], # file path or inline @perseus source
|
|
58
|
+
)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Single agent (callback)
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from google.adk.agents import Agent
|
|
65
|
+
from adk_perseus_context import perseus_before_model_callback
|
|
66
|
+
|
|
67
|
+
agent = Agent(
|
|
68
|
+
name="assistant",
|
|
69
|
+
model="gemini-flash-latest",
|
|
70
|
+
instruction="Help the user.",
|
|
71
|
+
before_model_callback=perseus_before_model_callback("context.perseus"),
|
|
72
|
+
)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Either way, the compiled Perseus context is appended to the request's system
|
|
76
|
+
instruction (via ADK's `LlmRequest.append_instructions`) on every model call.
|
|
77
|
+
|
|
78
|
+
## Per-session context
|
|
79
|
+
|
|
80
|
+
Override the source per session through session state — useful when each user or
|
|
81
|
+
task needs a different workspace or directive set:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
session = await runner.session_service.create_session(
|
|
85
|
+
app_name="my_app",
|
|
86
|
+
user_id="user",
|
|
87
|
+
state={
|
|
88
|
+
"_perseus_source": "@perseus\n@file AGENTS.md\n@memory deployment",
|
|
89
|
+
"_perseus_workspace": "/path/to/project",
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
State keys are exported as `adk_perseus_context.STATE_SOURCE` and
|
|
95
|
+
`STATE_WORKSPACE`. A per-session source takes precedence over the static one.
|
|
96
|
+
|
|
97
|
+
## Inline vs. file sources
|
|
98
|
+
|
|
99
|
+
`source` is either a path to a `.perseus` file or an inline source string that
|
|
100
|
+
starts with `@perseus`:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
PerseusContextPlugin("@perseus\n\nYou are a concise assistant. @file README.md")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
For a file source, the workspace defaults to the file's directory so relative
|
|
107
|
+
`@include` / `@file` paths resolve.
|
|
108
|
+
|
|
109
|
+
## Fail-open by default
|
|
110
|
+
|
|
111
|
+
If Perseus is missing or a compile raises, the request proceeds **without**
|
|
112
|
+
injected context and a warning is logged, so a context problem never takes your
|
|
113
|
+
agent down. Pass `fail_open=False` to make such errors propagate instead.
|
|
114
|
+
|
|
115
|
+
## How it works
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
before_model_callback
|
|
119
|
+
┌─────────────┐ ┌─────────────────────────┐ ┌──────────────┐
|
|
120
|
+
│ ADK Runner │ ──▶ │ PerseusContextPlugin │ ──▶ │ LlmRequest │
|
|
121
|
+
│ / Agent │ │ perseus.compile_context │ │ system_ │
|
|
122
|
+
└─────────────┘ │ (deterministic, local) │ │ instruction │
|
|
123
|
+
└─────────────────────────┘ └──────────────┘
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The plugin/callback calls `perseus.compile_context(source)` — Perseus's Context
|
|
127
|
+
Adapter SDK "resolve once" primitive — and appends the result to the system
|
|
128
|
+
instruction. Perseus owns deterministic assembly; ADK owns orchestration.
|
|
129
|
+
|
|
130
|
+
## Compose with Mimir
|
|
131
|
+
|
|
132
|
+
Perseus and Mimir are designed to compose: Mimir provides persistent, encrypted
|
|
133
|
+
memory; Perseus pulls hot memory into a compiled context via `@memory`
|
|
134
|
+
directives. Use `adk-mimir-memory` for the memory backend and this package for
|
|
135
|
+
the context layer.
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT — see [Perseus](https://github.com/Perseus-Computing-LLC/perseus) for the
|
|
140
|
+
backing context engine.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""adk-perseus-context — deterministic live context for Google ADK agents.
|
|
2
|
+
|
|
3
|
+
Perseus (github.com/Perseus-Computing-LLC/perseus) is an open-source (MIT)
|
|
4
|
+
*context compiler*: it resolves directives like ``@file``, ``@search``, and
|
|
5
|
+
``@memory`` into one deterministic, byte-stable context string at inference
|
|
6
|
+
time — no retrieval index, no embeddings, no LLM round-trip. This package wraps
|
|
7
|
+
that compiler as an ADK extension point so the compiled context lands in your
|
|
8
|
+
agent's system instruction on every model call.
|
|
9
|
+
|
|
10
|
+
Two entry points:
|
|
11
|
+
|
|
12
|
+
- ``PerseusContextPlugin`` — a ``BasePlugin`` that injects a Perseus context
|
|
13
|
+
across every agent in a ``Runner``.
|
|
14
|
+
- ``perseus_before_model_callback`` — a per-agent ``before_model_callback``.
|
|
15
|
+
|
|
16
|
+
Both build on Perseus's Context Adapter SDK (``perseus.compile_context``,
|
|
17
|
+
perseus-ctx >= 1.0.10).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .callback import perseus_before_model_callback
|
|
21
|
+
from .plugin import STATE_SOURCE, STATE_WORKSPACE, PerseusContextPlugin
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"PerseusContextPlugin",
|
|
25
|
+
"perseus_before_model_callback",
|
|
26
|
+
"STATE_SOURCE",
|
|
27
|
+
"STATE_WORKSPACE",
|
|
28
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Resolve a Perseus source to its compiled context string.
|
|
2
|
+
|
|
3
|
+
This is the one place that touches Perseus. It is imported lazily by the plugin
|
|
4
|
+
and the callback so that merely importing ``adk_perseus_context`` never requires
|
|
5
|
+
Perseus to be present until a context is actually compiled.
|
|
6
|
+
|
|
7
|
+
Perseus owns deterministic context assembly; everything here is a thin, fail-safe
|
|
8
|
+
wrapper around its public ``compile_context`` API (Context Adapter SDK, Perseus
|
|
9
|
+
>= 1.0.10). If Perseus is older and only exposes ``render_source``, an inline
|
|
10
|
+
source still works via a minimal fallback.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("adk_perseus_context")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PerseusUnavailableError(RuntimeError):
|
|
22
|
+
"""Raised when the ``perseus`` package cannot be imported."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _import_perseus():
|
|
26
|
+
try:
|
|
27
|
+
import perseus # noqa: PLC0415 - lazy by design
|
|
28
|
+
except Exception as e: # pragma: no cover - exercised only without the dep
|
|
29
|
+
raise PerseusUnavailableError(
|
|
30
|
+
"adk-perseus-context requires the 'perseus-ctx' package "
|
|
31
|
+
"(>=1.0.10 for the Context Adapter SDK). Install it with "
|
|
32
|
+
"`pip install perseus-ctx`."
|
|
33
|
+
) from e
|
|
34
|
+
return perseus
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def compile_source(
|
|
38
|
+
source: str,
|
|
39
|
+
*,
|
|
40
|
+
cfg: Optional[dict[str, Any]] = None,
|
|
41
|
+
workspace: Optional[str] = None,
|
|
42
|
+
) -> str:
|
|
43
|
+
"""Compile a Perseus ``source`` (inline ``@perseus`` text or a file path).
|
|
44
|
+
|
|
45
|
+
Returns the deterministic compiled context string. Raises
|
|
46
|
+
:class:`PerseusUnavailableError` if Perseus is not installed; lets any
|
|
47
|
+
Perseus compile error propagate so callers can decide whether to fail open.
|
|
48
|
+
"""
|
|
49
|
+
perseus = _import_perseus()
|
|
50
|
+
|
|
51
|
+
compile_context = getattr(perseus, "compile_context", None)
|
|
52
|
+
if compile_context is not None:
|
|
53
|
+
return compile_context(source, cfg=cfg, workspace=workspace)
|
|
54
|
+
|
|
55
|
+
# Fallback for Perseus < 1.0.10 (no Context Adapter SDK): inline sources only.
|
|
56
|
+
render_source = getattr(perseus, "render_source", None)
|
|
57
|
+
if render_source is None: # pragma: no cover - extremely old perseus
|
|
58
|
+
raise PerseusUnavailableError(
|
|
59
|
+
"Installed 'perseus-ctx' exposes neither compile_context nor "
|
|
60
|
+
"render_source; upgrade to perseus-ctx>=1.0.10."
|
|
61
|
+
)
|
|
62
|
+
is_inline = isinstance(source, str) and source.lstrip().startswith("@perseus")
|
|
63
|
+
if not is_inline:
|
|
64
|
+
raise PerseusUnavailableError(
|
|
65
|
+
"File-path sources need perseus-ctx>=1.0.10 (compile_context). "
|
|
66
|
+
"Upgrade perseus-ctx, or pass an inline '@perseus ...' source."
|
|
67
|
+
)
|
|
68
|
+
config = cfg
|
|
69
|
+
if config is None:
|
|
70
|
+
import copy # noqa: PLC0415
|
|
71
|
+
|
|
72
|
+
config = copy.deepcopy(getattr(perseus, "DEFAULT_CONFIG", {}))
|
|
73
|
+
return render_source(source, config, workspace)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""``perseus_before_model_callback`` — a per-agent ``before_model_callback``.
|
|
2
|
+
|
|
3
|
+
Use this when you want a Perseus context applied to a single agent rather than
|
|
4
|
+
every agent in a Runner:
|
|
5
|
+
|
|
6
|
+
from google.adk.agents import Agent
|
|
7
|
+
from adk_perseus_context import perseus_before_model_callback
|
|
8
|
+
|
|
9
|
+
agent = Agent(
|
|
10
|
+
name="assistant",
|
|
11
|
+
model="gemini-flash-latest",
|
|
12
|
+
before_model_callback=perseus_before_model_callback("context.perseus"),
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
For a Runner-wide context across many agents, use ``PerseusContextPlugin``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
from typing import Any, Optional
|
|
22
|
+
|
|
23
|
+
from ._resolve import PerseusUnavailableError, compile_source
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("adk_perseus_context")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def perseus_before_model_callback(
|
|
29
|
+
source: str,
|
|
30
|
+
*,
|
|
31
|
+
cfg: Optional[dict[str, Any]] = None,
|
|
32
|
+
workspace: Optional[str] = None,
|
|
33
|
+
fail_open: bool = True,
|
|
34
|
+
):
|
|
35
|
+
"""Build an agent-level ``before_model_callback`` that injects a Perseus context.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
source: Inline ``@perseus`` source string, or a path to a ``.perseus`` file.
|
|
39
|
+
cfg: Optional Perseus config dict (defaults to Perseus's ``DEFAULT_CONFIG``).
|
|
40
|
+
workspace: Workspace root for relative ``@include`` paths.
|
|
41
|
+
fail_open: If ``True`` (default), a missing Perseus install or compile
|
|
42
|
+
error logs a warning and lets the request proceed; if ``False``, it
|
|
43
|
+
propagates.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
An async ``(callback_context, llm_request)`` callable suitable for
|
|
47
|
+
``Agent(before_model_callback=...)``.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
async def _callback(callback_context, llm_request):
|
|
51
|
+
try:
|
|
52
|
+
context = compile_source(source, cfg=cfg, workspace=workspace)
|
|
53
|
+
except (PerseusUnavailableError, Exception) as e: # noqa: BLE001
|
|
54
|
+
if fail_open:
|
|
55
|
+
logger.warning("Perseus context injection skipped: %s", e)
|
|
56
|
+
return None
|
|
57
|
+
raise
|
|
58
|
+
if context and context.strip():
|
|
59
|
+
llm_request.append_instructions([context])
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
return _callback
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""``PerseusContextPlugin`` — inject a deterministically compiled Perseus context
|
|
2
|
+
into every model request of an ADK ``Runner``.
|
|
3
|
+
|
|
4
|
+
A Perseus source (inline ``@perseus`` text or a path to a ``.perseus`` file) is
|
|
5
|
+
compiled with :func:`perseus.compile_context` and appended to the request's
|
|
6
|
+
system instruction via ``LlmRequest.append_instructions`` — the supported,
|
|
7
|
+
type-safe way to add system instructions in ADK.
|
|
8
|
+
|
|
9
|
+
Use this when you want one context applied across all agents driven by a Runner.
|
|
10
|
+
For a single agent, prefer :func:`adk_perseus_context.perseus_before_model_callback`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
from google.adk.plugins import BasePlugin
|
|
19
|
+
|
|
20
|
+
from ._resolve import PerseusUnavailableError, compile_source
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("adk_perseus_context")
|
|
23
|
+
|
|
24
|
+
# Session-state keys for per-session overrides (read from callback_context.state).
|
|
25
|
+
STATE_SOURCE = "_perseus_source"
|
|
26
|
+
STATE_WORKSPACE = "_perseus_workspace"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PerseusContextPlugin(BasePlugin):
|
|
30
|
+
"""Compile a Perseus context once per model request and inject it.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
source: Inline ``@perseus`` source string, or a path to a ``.perseus``
|
|
34
|
+
file. May be ``None`` if every session supplies its own source via
|
|
35
|
+
the ``_perseus_source`` state key.
|
|
36
|
+
cfg: Optional Perseus config dict. Defaults to Perseus's ``DEFAULT_CONFIG``.
|
|
37
|
+
workspace: Workspace root for resolving relative ``@include`` paths. For a
|
|
38
|
+
file source it defaults to the file's directory.
|
|
39
|
+
fail_open: If ``True`` (default), a missing Perseus install or a compile
|
|
40
|
+
error logs a warning and lets the request proceed without injected
|
|
41
|
+
context. If ``False``, the error propagates.
|
|
42
|
+
name: Unique plugin identifier within the Runner.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
source: Optional[str] = None,
|
|
48
|
+
*,
|
|
49
|
+
cfg: Optional[dict[str, Any]] = None,
|
|
50
|
+
workspace: Optional[str] = None,
|
|
51
|
+
fail_open: bool = True,
|
|
52
|
+
name: str = "perseus_context",
|
|
53
|
+
) -> None:
|
|
54
|
+
super().__init__(name=name)
|
|
55
|
+
self.source = source
|
|
56
|
+
self.cfg = cfg
|
|
57
|
+
self.workspace = workspace
|
|
58
|
+
self.fail_open = fail_open
|
|
59
|
+
|
|
60
|
+
async def before_model_callback(self, *, callback_context, llm_request):
|
|
61
|
+
state = getattr(callback_context, "state", None) or {}
|
|
62
|
+
source = state.get(STATE_SOURCE, self.source)
|
|
63
|
+
workspace = state.get(STATE_WORKSPACE, self.workspace)
|
|
64
|
+
if not source:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
context = compile_source(source, cfg=self.cfg, workspace=workspace)
|
|
69
|
+
except (PerseusUnavailableError, Exception) as e: # noqa: BLE001
|
|
70
|
+
if self.fail_open:
|
|
71
|
+
logger.warning("Perseus context injection skipped: %s", e)
|
|
72
|
+
return None
|
|
73
|
+
raise
|
|
74
|
+
|
|
75
|
+
if context and context.strip():
|
|
76
|
+
llm_request.append_instructions([context])
|
|
77
|
+
# Return None so the model call proceeds normally.
|
|
78
|
+
return None
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Runnable example: a Perseus-compiled context injected into an ADK agent.
|
|
2
|
+
|
|
3
|
+
Two ways to wire it up are shown:
|
|
4
|
+
|
|
5
|
+
1. PerseusContextPlugin — one context across every agent in a Runner.
|
|
6
|
+
2. perseus_before_model_callback — one context for a single agent.
|
|
7
|
+
|
|
8
|
+
Set GOOGLE_API_KEY (or configure another ADK model provider) before running:
|
|
9
|
+
|
|
10
|
+
pip install adk-perseus-context
|
|
11
|
+
python examples/runner_plugin_example.py
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
from google.adk.agents import Agent
|
|
18
|
+
from google.adk.runners import Runner
|
|
19
|
+
from google.adk.sessions import InMemorySessionService
|
|
20
|
+
from google.genai import types
|
|
21
|
+
|
|
22
|
+
from adk_perseus_context import PerseusContextPlugin, perseus_before_model_callback
|
|
23
|
+
|
|
24
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
25
|
+
CONTEXT = os.path.join(HERE, "context.perseus")
|
|
26
|
+
|
|
27
|
+
MODEL = "gemini-flash-latest"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_runner_with_plugin() -> Runner:
|
|
31
|
+
"""Inject the Perseus context across all agents via a Runner-wide plugin."""
|
|
32
|
+
agent = Agent(name="assistant", model=MODEL, instruction="Help the user.")
|
|
33
|
+
return Runner(
|
|
34
|
+
agent=agent,
|
|
35
|
+
app_name="perseus_ctx_app",
|
|
36
|
+
session_service=InMemorySessionService(),
|
|
37
|
+
plugins=[PerseusContextPlugin(CONTEXT)],
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_runner_with_callback() -> Runner:
|
|
42
|
+
"""Inject the Perseus context for a single agent via before_model_callback."""
|
|
43
|
+
agent = Agent(
|
|
44
|
+
name="assistant",
|
|
45
|
+
model=MODEL,
|
|
46
|
+
instruction="Help the user.",
|
|
47
|
+
before_model_callback=perseus_before_model_callback(CONTEXT),
|
|
48
|
+
)
|
|
49
|
+
return Runner(
|
|
50
|
+
agent=agent,
|
|
51
|
+
app_name="perseus_ctx_app",
|
|
52
|
+
session_service=InMemorySessionService(),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def main() -> None:
|
|
57
|
+
runner = build_runner_with_plugin() # or build_runner_with_callback()
|
|
58
|
+
session = await runner.session_service.create_session(
|
|
59
|
+
app_name="perseus_ctx_app", user_id="user"
|
|
60
|
+
)
|
|
61
|
+
msg = types.Content(role="user", parts=[types.Part.from_text(text="Who are you?")])
|
|
62
|
+
async for event in runner.run_async(
|
|
63
|
+
user_id="user", session_id=session.id, new_message=msg
|
|
64
|
+
):
|
|
65
|
+
if event.content and event.content.parts:
|
|
66
|
+
for part in event.content.parts:
|
|
67
|
+
if part.text:
|
|
68
|
+
print(part.text)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
if not os.environ.get("GOOGLE_API_KEY"):
|
|
73
|
+
print("Set GOOGLE_API_KEY to run this example against Gemini.")
|
|
74
|
+
else:
|
|
75
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "adk-perseus-context"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Thomas Connally", email = "51974392+tcconnally@users.noreply.github.com" },
|
|
10
|
+
]
|
|
11
|
+
description = "Deterministic live context for Google ADK agents — compiled by Perseus, injected as system instruction."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
license = { text = "MIT" }
|
|
14
|
+
requires-python = ">=3.10"
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"google-adk>=1.0.0",
|
|
28
|
+
"perseus-ctx>=1.0.10",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = ["pytest", "pytest-asyncio"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/Perseus-Computing-LLC/adk-perseus-context"
|
|
36
|
+
Repository = "https://github.com/Perseus-Computing-LLC/adk-perseus-context"
|
|
37
|
+
"Bug Tracker" = "https://github.com/Perseus-Computing-LLC/adk-perseus-context/issues"
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Tests for adk-perseus-context.
|
|
2
|
+
|
|
3
|
+
These tests stub the ``perseus`` package with a fake ``compile_context`` so they
|
|
4
|
+
run without perseus-ctx installed, but exercise the *real* ADK ``LlmRequest`` and
|
|
5
|
+
its ``append_instructions`` injection path.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import types
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from google.adk.models import LlmRequest
|
|
13
|
+
|
|
14
|
+
from adk_perseus_context import (
|
|
15
|
+
PerseusContextPlugin,
|
|
16
|
+
perseus_before_model_callback,
|
|
17
|
+
)
|
|
18
|
+
from adk_perseus_context import _resolve
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def fake_perseus(monkeypatch):
|
|
23
|
+
"""Install a fake ``perseus`` module exposing ``compile_context``."""
|
|
24
|
+
mod = types.ModuleType("perseus")
|
|
25
|
+
|
|
26
|
+
def compile_context(source, cfg=None, workspace=None, max_tier=3):
|
|
27
|
+
# Echo the inputs so assertions can verify they were threaded through.
|
|
28
|
+
ws = f"|ws={workspace}" if workspace else ""
|
|
29
|
+
return f"COMPILED::{source}{ws}"
|
|
30
|
+
|
|
31
|
+
mod.compile_context = compile_context
|
|
32
|
+
monkeypatch.setitem(sys.modules, "perseus", mod)
|
|
33
|
+
return mod
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _Ctx:
|
|
37
|
+
"""Minimal stand-in for ADK's CallbackContext (only ``.state`` is read)."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, state=None):
|
|
40
|
+
self.state = state or {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.asyncio
|
|
44
|
+
async def test_plugin_injects_into_system_instruction(fake_perseus):
|
|
45
|
+
plugin = PerseusContextPlugin("@perseus\n\nhello")
|
|
46
|
+
req = LlmRequest()
|
|
47
|
+
result = await plugin.before_model_callback(
|
|
48
|
+
callback_context=_Ctx(), llm_request=req
|
|
49
|
+
)
|
|
50
|
+
assert result is None # never short-circuits the model call
|
|
51
|
+
assert req.config.system_instruction == "COMPILED::@perseus\n\nhello"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.mark.asyncio
|
|
55
|
+
async def test_plugin_appends_to_existing_instruction(fake_perseus):
|
|
56
|
+
plugin = PerseusContextPlugin("@perseus\n\nhello")
|
|
57
|
+
req = LlmRequest()
|
|
58
|
+
req.config.system_instruction = "preexisting"
|
|
59
|
+
await plugin.before_model_callback(callback_context=_Ctx(), llm_request=req)
|
|
60
|
+
assert req.config.system_instruction == "preexisting\n\nCOMPILED::@perseus\n\nhello"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.asyncio
|
|
64
|
+
async def test_session_state_override(fake_perseus):
|
|
65
|
+
plugin = PerseusContextPlugin("@perseus\n\ndefault")
|
|
66
|
+
req = LlmRequest()
|
|
67
|
+
ctx = _Ctx(
|
|
68
|
+
{"_perseus_source": "@perseus\n\noverride", "_perseus_workspace": "/proj"}
|
|
69
|
+
)
|
|
70
|
+
await plugin.before_model_callback(callback_context=ctx, llm_request=req)
|
|
71
|
+
assert req.config.system_instruction == "COMPILED::@perseus\n\noverride|ws=/proj"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.asyncio
|
|
75
|
+
async def test_no_source_is_noop(fake_perseus):
|
|
76
|
+
plugin = PerseusContextPlugin() # no static source, no state source
|
|
77
|
+
req = LlmRequest()
|
|
78
|
+
await plugin.before_model_callback(callback_context=_Ctx(), llm_request=req)
|
|
79
|
+
assert req.config.system_instruction is None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.asyncio
|
|
83
|
+
async def test_empty_compiled_context_is_noop(monkeypatch):
|
|
84
|
+
mod = types.ModuleType("perseus")
|
|
85
|
+
mod.compile_context = lambda *a, **k: " " # whitespace only
|
|
86
|
+
monkeypatch.setitem(sys.modules, "perseus", mod)
|
|
87
|
+
plugin = PerseusContextPlugin("@perseus\n\n")
|
|
88
|
+
req = LlmRequest()
|
|
89
|
+
await plugin.before_model_callback(callback_context=_Ctx(), llm_request=req)
|
|
90
|
+
assert req.config.system_instruction is None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.mark.asyncio
|
|
94
|
+
async def test_fail_open_when_perseus_unavailable(monkeypatch):
|
|
95
|
+
# Force the import to fail.
|
|
96
|
+
monkeypatch.setitem(sys.modules, "perseus", None)
|
|
97
|
+
plugin = PerseusContextPlugin("@perseus\n\nhello", fail_open=True)
|
|
98
|
+
req = LlmRequest()
|
|
99
|
+
result = await plugin.before_model_callback(
|
|
100
|
+
callback_context=_Ctx(), llm_request=req
|
|
101
|
+
)
|
|
102
|
+
assert result is None
|
|
103
|
+
assert req.config.system_instruction is None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@pytest.mark.asyncio
|
|
107
|
+
async def test_fail_closed_raises(monkeypatch):
|
|
108
|
+
def boom(*a, **k):
|
|
109
|
+
raise RuntimeError("compile blew up")
|
|
110
|
+
|
|
111
|
+
mod = types.ModuleType("perseus")
|
|
112
|
+
mod.compile_context = boom
|
|
113
|
+
monkeypatch.setitem(sys.modules, "perseus", mod)
|
|
114
|
+
plugin = PerseusContextPlugin("@perseus\n\nhello", fail_open=False)
|
|
115
|
+
req = LlmRequest()
|
|
116
|
+
with pytest.raises(RuntimeError, match="compile blew up"):
|
|
117
|
+
await plugin.before_model_callback(callback_context=_Ctx(), llm_request=req)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.asyncio
|
|
121
|
+
async def test_callback_factory_injects(fake_perseus):
|
|
122
|
+
cb = perseus_before_model_callback("@perseus\n\nfrom-callback")
|
|
123
|
+
req = LlmRequest()
|
|
124
|
+
result = await cb(_Ctx(), req) # agent-level callback is positional
|
|
125
|
+
assert result is None
|
|
126
|
+
assert req.config.system_instruction == "COMPILED::@perseus\n\nfrom-callback"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_resolve_fallback_to_render_source(monkeypatch):
|
|
130
|
+
"""Perseus < 1.0.10 (render_source only) still works for inline sources."""
|
|
131
|
+
mod = types.ModuleType("perseus")
|
|
132
|
+
mod.DEFAULT_CONFIG = {}
|
|
133
|
+
mod.render_source = lambda src, cfg, ws: f"RENDERED::{src}"
|
|
134
|
+
monkeypatch.setitem(sys.modules, "perseus", mod)
|
|
135
|
+
out = _resolve.compile_source("@perseus\n\nx")
|
|
136
|
+
assert out == "RENDERED::@perseus\n\nx"
|