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.
@@ -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,7 @@
1
+ dist/
2
+ build/
3
+ *.egg-info/
4
+ __pycache__/
5
+ *.pyc
6
+ .env
7
+ .pytest_cache/
@@ -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,5 @@
1
+ @perseus
2
+
3
+ You are a workspace-aware assistant for the Perseus Computing demo project.
4
+
5
+ Be concise. Cite file paths when you reference code.
@@ -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"