copilotkit-intelligence-adk 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.
- copilotkit_intelligence_adk-0.1.0/.gitignore +1 -0
- copilotkit_intelligence_adk-0.1.0/LICENSE +21 -0
- copilotkit_intelligence_adk-0.1.0/PKG-INFO +52 -0
- copilotkit_intelligence_adk-0.1.0/README.md +41 -0
- copilotkit_intelligence_adk-0.1.0/_vendor/_delivery/__init__.py +1 -0
- copilotkit_intelligence_adk-0.1.0/_vendor/_delivery/config.py +75 -0
- copilotkit_intelligence_adk-0.1.0/_vendor/_delivery/registry.py +210 -0
- copilotkit_intelligence_adk-0.1.0/_vendor/_delivery/snapshot.py +223 -0
- copilotkit_intelligence_adk-0.1.0/build_hook.py +29 -0
- copilotkit_intelligence_adk-0.1.0/pyproject.toml +42 -0
- copilotkit_intelligence_adk-0.1.0/src/copilotkit_intelligence_adk/__init__.py +7 -0
- copilotkit_intelligence_adk-0.1.0/src/copilotkit_intelligence_adk/py.typed +0 -0
- copilotkit_intelligence_adk-0.1.0/src/copilotkit_intelligence_adk/skills.py +184 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/src/copilotkit_intelligence_adk/_delivery/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Atai Barkai
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: copilotkit-intelligence-adk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Automatic learned skill delivery for native ADK agents
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: copilotkit-intelligence-runtime<0.2,>=0.1.0
|
|
9
|
+
Requires-Dist: google-adk<2,>=1.17
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# CopilotKit Intelligence ADK
|
|
13
|
+
|
|
14
|
+
`SkillRegistry` and `SkillToolset` deliver one Learning container's published skills to standard ADK `LlmAgent` instances. The adapter supports `google-adk>=1.17,<2`. It does not patch arbitrary custom `BaseAgent` implementations.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from copilotkit_intelligence import Intelligence
|
|
18
|
+
from copilotkit_intelligence_adk import SkillRegistry, SkillToolset
|
|
19
|
+
from google.adk.agents import LlmAgent
|
|
20
|
+
|
|
21
|
+
async with Intelligence(api_key="your-project-key") as intelligence:
|
|
22
|
+
registry = SkillRegistry(
|
|
23
|
+
client=intelligence,
|
|
24
|
+
container_id="your-learning-container",
|
|
25
|
+
)
|
|
26
|
+
await registry.initialize()
|
|
27
|
+
agent = LlmAgent(
|
|
28
|
+
name="assistant",
|
|
29
|
+
model="your-model",
|
|
30
|
+
instruction="Your application instructions.",
|
|
31
|
+
tools=[SkillToolset(registry)],
|
|
32
|
+
)
|
|
33
|
+
# Use the agent with the application's normal async ADK Runner.
|
|
34
|
+
# After all runners finish, release the registry's owned resources.
|
|
35
|
+
await registry.aclose()
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
A registry can serve several explicitly selected agents. `SkillToolset.close()` does not close that shared application-owned registry. `registry.aclose()` closes a helper-created canonical client but leaves an injected client and HTTP pool application-owned. Startup errors are catchable and initialization can retry. `status` exposes immutable `initialized`, `revision`, `mode`, `last_checked_at`, `stale`, and `last_error` values.
|
|
39
|
+
|
|
40
|
+
`SkillRegistry` accepts `client`, `api_key`, `api_url`, `container_id`, `revision`, `freshness_window`, `request_timeout`, and `debug`. Durations use seconds and default to five. Debug defaults to false. Explicit values override `CPK_INTELLIGENCE_API_KEY`, `INTELLIGENCE_API_URL`, `CPK_INTELLIGENCE_LEARNING_CONTAINER_ID`, and `CPK_INTELLIGENCE_SKILLS_REVISION`. An injected canonical client supplies all connection configuration.
|
|
41
|
+
|
|
42
|
+
The toolset always exposes `copilotkit_load_skill` and `copilotkit_read_skill_file`, including for an empty container. Its native `process_llm_request` hook waits for an authorized snapshot before the model runs, then appends an alphabetical catalog. Developer-authored instructions outrank learned skills. The model chooses which skills to use. Tool discovery does not perform authorization, because ADK can suppress discovery failures.
|
|
43
|
+
|
|
44
|
+
Each pin belongs to the actual native session object and invocation ID. Parallel hooks and tools share that pin. A new run or resumed invocation receives a fresh native context and rechecks the registry according to its freshness window. The adapter stores no UUID, snapshot, or lock in session state or event deltas. Copied state cannot select another invocation's pin. Weak session ownership releases private pins when the invocation session is collected, including after cancellation or denial.
|
|
45
|
+
|
|
46
|
+
Tool results support manifest-listed UTF-8 text only. Unknown skills, unlisted paths, and binary files produce ADK function responses with an `error` field. The adapter never executes scripts or writes skill files. Completed tool output is ordinary model history and can be persisted by the host framework. Subagents follow native ADK behavior; attach the toolset explicitly to each LLM agent that needs skills.
|
|
47
|
+
|
|
48
|
+
Warm transient failures retain the previous snapshot indefinitely and mark status stale. Confirmed denial blocks new invocations. An explicit revision pins the complete skill set and never falls back to latest. Existing invocation pins stay unchanged while later registry refreshes complete.
|
|
49
|
+
|
|
50
|
+
Builds vendor shared private source into `copilotkit_intelligence_adk._delivery`. There is no dependency on another public adapter or a separate public core distribution. The canonical runtime dependency must be published with learned-snapshot and per-request deadline support before release. Its existing Runtime dependencies remain part of the installation.
|
|
51
|
+
|
|
52
|
+
Repository checks use Nx targets `intelligence-adk-python:test`, `:test-minimum`, `:test-latest`, `:typecheck`, `:lint`, `:build`, and `:verify-distribution`. The distribution check rebuilds the sdist outside the checkout and imports the resulting wheel without editable adapter source. Resumability tests use ADK's native experimental `ResumabilityConfig`; applications retain control over enabling that framework feature.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# CopilotKit Intelligence ADK
|
|
2
|
+
|
|
3
|
+
`SkillRegistry` and `SkillToolset` deliver one Learning container's published skills to standard ADK `LlmAgent` instances. The adapter supports `google-adk>=1.17,<2`. It does not patch arbitrary custom `BaseAgent` implementations.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from copilotkit_intelligence import Intelligence
|
|
7
|
+
from copilotkit_intelligence_adk import SkillRegistry, SkillToolset
|
|
8
|
+
from google.adk.agents import LlmAgent
|
|
9
|
+
|
|
10
|
+
async with Intelligence(api_key="your-project-key") as intelligence:
|
|
11
|
+
registry = SkillRegistry(
|
|
12
|
+
client=intelligence,
|
|
13
|
+
container_id="your-learning-container",
|
|
14
|
+
)
|
|
15
|
+
await registry.initialize()
|
|
16
|
+
agent = LlmAgent(
|
|
17
|
+
name="assistant",
|
|
18
|
+
model="your-model",
|
|
19
|
+
instruction="Your application instructions.",
|
|
20
|
+
tools=[SkillToolset(registry)],
|
|
21
|
+
)
|
|
22
|
+
# Use the agent with the application's normal async ADK Runner.
|
|
23
|
+
# After all runners finish, release the registry's owned resources.
|
|
24
|
+
await registry.aclose()
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
A registry can serve several explicitly selected agents. `SkillToolset.close()` does not close that shared application-owned registry. `registry.aclose()` closes a helper-created canonical client but leaves an injected client and HTTP pool application-owned. Startup errors are catchable and initialization can retry. `status` exposes immutable `initialized`, `revision`, `mode`, `last_checked_at`, `stale`, and `last_error` values.
|
|
28
|
+
|
|
29
|
+
`SkillRegistry` accepts `client`, `api_key`, `api_url`, `container_id`, `revision`, `freshness_window`, `request_timeout`, and `debug`. Durations use seconds and default to five. Debug defaults to false. Explicit values override `CPK_INTELLIGENCE_API_KEY`, `INTELLIGENCE_API_URL`, `CPK_INTELLIGENCE_LEARNING_CONTAINER_ID`, and `CPK_INTELLIGENCE_SKILLS_REVISION`. An injected canonical client supplies all connection configuration.
|
|
30
|
+
|
|
31
|
+
The toolset always exposes `copilotkit_load_skill` and `copilotkit_read_skill_file`, including for an empty container. Its native `process_llm_request` hook waits for an authorized snapshot before the model runs, then appends an alphabetical catalog. Developer-authored instructions outrank learned skills. The model chooses which skills to use. Tool discovery does not perform authorization, because ADK can suppress discovery failures.
|
|
32
|
+
|
|
33
|
+
Each pin belongs to the actual native session object and invocation ID. Parallel hooks and tools share that pin. A new run or resumed invocation receives a fresh native context and rechecks the registry according to its freshness window. The adapter stores no UUID, snapshot, or lock in session state or event deltas. Copied state cannot select another invocation's pin. Weak session ownership releases private pins when the invocation session is collected, including after cancellation or denial.
|
|
34
|
+
|
|
35
|
+
Tool results support manifest-listed UTF-8 text only. Unknown skills, unlisted paths, and binary files produce ADK function responses with an `error` field. The adapter never executes scripts or writes skill files. Completed tool output is ordinary model history and can be persisted by the host framework. Subagents follow native ADK behavior; attach the toolset explicitly to each LLM agent that needs skills.
|
|
36
|
+
|
|
37
|
+
Warm transient failures retain the previous snapshot indefinitely and mark status stale. Confirmed denial blocks new invocations. An explicit revision pins the complete skill set and never falls back to latest. Existing invocation pins stay unchanged while later registry refreshes complete.
|
|
38
|
+
|
|
39
|
+
Builds vendor shared private source into `copilotkit_intelligence_adk._delivery`. There is no dependency on another public adapter or a separate public core distribution. The canonical runtime dependency must be published with learned-snapshot and per-request deadline support before release. Its existing Runtime dependencies remain part of the installation.
|
|
40
|
+
|
|
41
|
+
Repository checks use Nx targets `intelligence-adk-python:test`, `:test-minimum`, `:test-latest`, `:typecheck`, `:lint`, `:build`, and `:verify-distribution`. The distribution check rebuilds the sdist outside the checkout and imports the resulting wheel without editable adapter source. Resumability tests use ADK's native experimental `ResumabilityConfig`; applications retain control over enabling that framework feature.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Private source shared by framework adapters through vendoring."""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Resolve private registry configuration without a second authenticated transport."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from copilotkit_intelligence import Intelligence, LearnedSkillsError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class Config:
|
|
13
|
+
client: Intelligence
|
|
14
|
+
container_id: str
|
|
15
|
+
revision: str | None
|
|
16
|
+
freshness_window: float
|
|
17
|
+
request_timeout: float
|
|
18
|
+
debug: bool
|
|
19
|
+
owns_client: bool
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolve_config(
|
|
23
|
+
*,
|
|
24
|
+
client: Intelligence | None = None,
|
|
25
|
+
api_key: str | None = None,
|
|
26
|
+
api_url: str | None = None,
|
|
27
|
+
container_id: str | None = None,
|
|
28
|
+
revision: str | None = None,
|
|
29
|
+
freshness_window: float = 5,
|
|
30
|
+
request_timeout: float = 5,
|
|
31
|
+
debug: bool = False,
|
|
32
|
+
environment: Mapping[str, str] | None = None,
|
|
33
|
+
) -> Config:
|
|
34
|
+
"""Explicit values win; injected clients supply all connection configuration."""
|
|
35
|
+
env = os.environ if environment is None else environment
|
|
36
|
+
try:
|
|
37
|
+
container_id = (
|
|
38
|
+
container_id
|
|
39
|
+
if container_id is not None
|
|
40
|
+
else env.get("CPK_INTELLIGENCE_LEARNING_CONTAINER_ID")
|
|
41
|
+
)
|
|
42
|
+
revision = revision if revision is not None else env.get("CPK_INTELLIGENCE_SKILLS_REVISION")
|
|
43
|
+
if (
|
|
44
|
+
not isinstance(container_id, str)
|
|
45
|
+
or not container_id.strip()
|
|
46
|
+
or (revision is not None and (not isinstance(revision, str) or not revision))
|
|
47
|
+
or type(freshness_window) not in (int, float)
|
|
48
|
+
or not math.isfinite(freshness_window)
|
|
49
|
+
or freshness_window < 0
|
|
50
|
+
or type(request_timeout) not in (int, float)
|
|
51
|
+
or not math.isfinite(request_timeout)
|
|
52
|
+
or request_timeout <= 0
|
|
53
|
+
or type(debug) is not bool
|
|
54
|
+
):
|
|
55
|
+
raise LearnedSkillsError("INVALID_CONFIG", False)
|
|
56
|
+
owns_client = client is None
|
|
57
|
+
if client is None:
|
|
58
|
+
key = api_key if api_key is not None else env.get("CPK_INTELLIGENCE_API_KEY")
|
|
59
|
+
endpoint = api_url if api_url is not None else env.get("INTELLIGENCE_API_URL")
|
|
60
|
+
if not isinstance(key, str) or not key.strip():
|
|
61
|
+
raise LearnedSkillsError("INVALID_CONFIG", False)
|
|
62
|
+
client = (
|
|
63
|
+
Intelligence(api_key=key, api_url=endpoint)
|
|
64
|
+
if endpoint is not None
|
|
65
|
+
else Intelligence(api_key=key)
|
|
66
|
+
)
|
|
67
|
+
elif not callable(getattr(client, "get_learned_skills_snapshot", None)):
|
|
68
|
+
raise LearnedSkillsError("INVALID_CONFIG", False)
|
|
69
|
+
return Config(
|
|
70
|
+
client, container_id, revision, freshness_window, request_timeout, debug, owns_client
|
|
71
|
+
)
|
|
72
|
+
except LearnedSkillsError:
|
|
73
|
+
raise
|
|
74
|
+
except Exception as error:
|
|
75
|
+
raise LearnedSkillsError("INVALID_CONFIG", False, error) from None
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Private asyncio registry with immutable snapshots and one shared refresh."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from time import monotonic, time
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from copilotkit_intelligence import Intelligence, LearnedSkillsError, LearnedSkillsErrorCode
|
|
11
|
+
|
|
12
|
+
from .config import resolve_config
|
|
13
|
+
from .snapshot import VerifiedSnapshot, invalid_snapshot, validate_snapshot
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
_TRANSIENT = frozenset({"NETWORK_ERROR", "TIMEOUT", "INVALID_SNAPSHOT", "UNSUPPORTED_SERVER"})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class ErrorStatus:
|
|
21
|
+
code: LearnedSkillsErrorCode
|
|
22
|
+
message: str
|
|
23
|
+
retryable: bool
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class Status:
|
|
28
|
+
initialized: bool
|
|
29
|
+
revision: str | None
|
|
30
|
+
mode: Literal["latest", "pinned"]
|
|
31
|
+
last_checked_at: str | None
|
|
32
|
+
stale: bool
|
|
33
|
+
last_error: ErrorStatus | None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _consume(task: asyncio.Task[VerifiedSnapshot]) -> None:
|
|
37
|
+
"""Observe abandoned task failures without logging exception content."""
|
|
38
|
+
if not task.cancelled():
|
|
39
|
+
task.exception()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Registry:
|
|
43
|
+
"""Internal registry, vendored into each framework's private delivery namespace."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
client: Intelligence | None = None,
|
|
49
|
+
api_key: str | None = None,
|
|
50
|
+
api_url: str | None = None,
|
|
51
|
+
container_id: str | None = None,
|
|
52
|
+
revision: str | None = None,
|
|
53
|
+
freshness_window: float = 5,
|
|
54
|
+
request_timeout: float = 5,
|
|
55
|
+
debug: bool = False,
|
|
56
|
+
) -> None:
|
|
57
|
+
self._config = resolve_config(
|
|
58
|
+
client=client,
|
|
59
|
+
api_key=api_key,
|
|
60
|
+
api_url=api_url,
|
|
61
|
+
container_id=container_id,
|
|
62
|
+
revision=revision,
|
|
63
|
+
freshness_window=freshness_window,
|
|
64
|
+
request_timeout=request_timeout,
|
|
65
|
+
debug=debug,
|
|
66
|
+
)
|
|
67
|
+
self._snapshot: VerifiedSnapshot | None = None
|
|
68
|
+
self._inflight: asyncio.Task[VerifiedSnapshot] | None = None
|
|
69
|
+
self._checked: float | None = None
|
|
70
|
+
self._checked_at: str | None = None
|
|
71
|
+
self._stale = False
|
|
72
|
+
self._error: ErrorStatus | None = None
|
|
73
|
+
self._blocked: ErrorStatus | None = None
|
|
74
|
+
self._closed = False
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def status(self) -> Status:
|
|
78
|
+
return Status(
|
|
79
|
+
self._snapshot is not None,
|
|
80
|
+
self._snapshot.revision if self._snapshot else None,
|
|
81
|
+
"pinned" if self._config.revision is not None else "latest",
|
|
82
|
+
self._checked_at,
|
|
83
|
+
self._stale,
|
|
84
|
+
self._error,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
async def initialize(self) -> None:
|
|
88
|
+
await self.acquire_snapshot()
|
|
89
|
+
|
|
90
|
+
async def acquire_snapshot(self) -> VerifiedSnapshot:
|
|
91
|
+
if self._closed:
|
|
92
|
+
raise LearnedSkillsError("INVALID_CONFIG", False)
|
|
93
|
+
if self._inflight is None:
|
|
94
|
+
if (
|
|
95
|
+
self._snapshot is not None
|
|
96
|
+
and self._blocked is None
|
|
97
|
+
and self._checked is not None
|
|
98
|
+
and monotonic() - self._checked < self._config.freshness_window
|
|
99
|
+
):
|
|
100
|
+
return self._snapshot
|
|
101
|
+
self._inflight = asyncio.create_task(self._refresh())
|
|
102
|
+
self._inflight.add_done_callback(self._finished)
|
|
103
|
+
# One cancelled invocation must not cancel another invocation's refresh.
|
|
104
|
+
return await asyncio.shield(self._inflight)
|
|
105
|
+
|
|
106
|
+
def _finished(self, task: asyncio.Task[VerifiedSnapshot]) -> None:
|
|
107
|
+
if self._inflight is task:
|
|
108
|
+
self._inflight = None
|
|
109
|
+
_consume(task)
|
|
110
|
+
|
|
111
|
+
async def aclose(self) -> None:
|
|
112
|
+
self._closed = True
|
|
113
|
+
if self._inflight is not None:
|
|
114
|
+
self._inflight.cancel()
|
|
115
|
+
await asyncio.gather(self._inflight, return_exceptions=True)
|
|
116
|
+
if self._config.owns_client:
|
|
117
|
+
await self._config.client.aclose()
|
|
118
|
+
|
|
119
|
+
async def _load(self, started: float) -> VerifiedSnapshot:
|
|
120
|
+
response = await self._config.client.get_learned_skills_snapshot(
|
|
121
|
+
container_id=self._config.container_id,
|
|
122
|
+
revision=self._config.revision,
|
|
123
|
+
if_none_match=self._snapshot.etag if self._snapshot else None,
|
|
124
|
+
request_timeout=self._config.request_timeout,
|
|
125
|
+
)
|
|
126
|
+
if not isinstance(response, dict):
|
|
127
|
+
raise invalid_snapshot()
|
|
128
|
+
if response.get("status") == "unchanged":
|
|
129
|
+
if (
|
|
130
|
+
self._snapshot is None
|
|
131
|
+
or response.get("revision") != self._snapshot.revision
|
|
132
|
+
or response.get("etag") != self._snapshot.etag
|
|
133
|
+
):
|
|
134
|
+
raise invalid_snapshot()
|
|
135
|
+
return self._snapshot
|
|
136
|
+
if self._config.revision is not None and response.get("revision") != self._config.revision:
|
|
137
|
+
raise invalid_snapshot()
|
|
138
|
+
# Capture metadata and owned bytes before handing work to another thread.
|
|
139
|
+
# A caller-owned response dictionary must not change an exact pin in flight.
|
|
140
|
+
raw = response.get("bytes")
|
|
141
|
+
if not isinstance(raw, (bytes, bytearray)):
|
|
142
|
+
raise invalid_snapshot()
|
|
143
|
+
captured = {
|
|
144
|
+
"status": response.get("status"),
|
|
145
|
+
"bytes": bytes(raw),
|
|
146
|
+
"revision": response.get("revision"),
|
|
147
|
+
"etag": response.get("etag"),
|
|
148
|
+
"contentType": response.get("contentType"),
|
|
149
|
+
}
|
|
150
|
+
remaining = self._config.request_timeout - (monotonic() - started)
|
|
151
|
+
if remaining <= 0:
|
|
152
|
+
raise LearnedSkillsError("TIMEOUT", True)
|
|
153
|
+
async with asyncio.timeout(remaining):
|
|
154
|
+
snapshot = await asyncio.to_thread(validate_snapshot, captured)
|
|
155
|
+
if self._config.revision is not None and snapshot.revision != self._config.revision:
|
|
156
|
+
raise invalid_snapshot()
|
|
157
|
+
return snapshot
|
|
158
|
+
|
|
159
|
+
async def _refresh(self) -> VerifiedSnapshot:
|
|
160
|
+
started = monotonic()
|
|
161
|
+
try:
|
|
162
|
+
# The canonical client owns the HTTP deadline. A second timer would
|
|
163
|
+
# race confirmed denial against transport cleanup. Validation uses
|
|
164
|
+
# only the remaining portion of this same invocation budget.
|
|
165
|
+
snapshot = await self._load(started)
|
|
166
|
+
self._snapshot = snapshot
|
|
167
|
+
self._checked = monotonic()
|
|
168
|
+
self._checked_at = (
|
|
169
|
+
datetime.fromtimestamp(time(), UTC)
|
|
170
|
+
.isoformat(timespec="milliseconds")
|
|
171
|
+
.replace("+00:00", "Z")
|
|
172
|
+
)
|
|
173
|
+
self._stale = False
|
|
174
|
+
self._error = self._blocked = None
|
|
175
|
+
self._debug("checked", started)
|
|
176
|
+
return snapshot
|
|
177
|
+
except asyncio.CancelledError:
|
|
178
|
+
raise
|
|
179
|
+
except Exception as cause:
|
|
180
|
+
error = (
|
|
181
|
+
cause
|
|
182
|
+
if isinstance(cause, LearnedSkillsError)
|
|
183
|
+
else LearnedSkillsError(
|
|
184
|
+
"TIMEOUT" if isinstance(cause, TimeoutError) else "NETWORK_ERROR", True, cause
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
info = ErrorStatus(error.code, error.message, error.retryable)
|
|
188
|
+
if error.code not in _TRANSIENT:
|
|
189
|
+
self._blocked = info
|
|
190
|
+
self._error = self._blocked if self._blocked is not None else info
|
|
191
|
+
self._debug("failed", started)
|
|
192
|
+
if self._blocked is not None:
|
|
193
|
+
self._stale = False
|
|
194
|
+
blocked = self._blocked
|
|
195
|
+
raise LearnedSkillsError(blocked.code, blocked.retryable, error.cause) from None
|
|
196
|
+
if self._snapshot is not None:
|
|
197
|
+
self._stale = True
|
|
198
|
+
return self._snapshot
|
|
199
|
+
raise error from None
|
|
200
|
+
|
|
201
|
+
def _debug(self, event: str, started: float) -> None:
|
|
202
|
+
if self._config.debug:
|
|
203
|
+
logger.debug(
|
|
204
|
+
"Learned skills %s container=%s revision=%s duration_ms=%.1f error_code=%s",
|
|
205
|
+
event,
|
|
206
|
+
self._config.container_id,
|
|
207
|
+
self._snapshot.revision if self._snapshot else None,
|
|
208
|
+
(monotonic() - started) * 1000,
|
|
209
|
+
self._error.code if self._error else None,
|
|
210
|
+
)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Validate complete skill snapshots in memory before registry installation."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import struct
|
|
8
|
+
import zipfile
|
|
9
|
+
import zlib
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from copilotkit_intelligence import LearnedSkillsError
|
|
13
|
+
|
|
14
|
+
MAX_SNAPSHOT_BYTES = 32 * 1024 * 1024
|
|
15
|
+
MAX_ARCHIVE_ENTRIES = 1000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class SnapshotFile:
|
|
20
|
+
path: str
|
|
21
|
+
size: int
|
|
22
|
+
sha256: str
|
|
23
|
+
text: str | None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class SnapshotSkill:
|
|
28
|
+
name: str
|
|
29
|
+
description: str
|
|
30
|
+
files: tuple[SnapshotFile, ...]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class VerifiedSnapshot:
|
|
35
|
+
revision: str
|
|
36
|
+
etag: str
|
|
37
|
+
skills: tuple[SnapshotSkill, ...]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def invalid_snapshot(cause: BaseException | None = None) -> LearnedSkillsError:
|
|
41
|
+
return LearnedSkillsError("INVALID_SNAPSHOT", False, cause)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def safe_path(path: object) -> bool:
|
|
45
|
+
return (
|
|
46
|
+
isinstance(path, str)
|
|
47
|
+
and bool(path)
|
|
48
|
+
and not re.search(r"[\\\x00-\x1f\x7f]", path)
|
|
49
|
+
and not re.match(r"^[a-z]:", path, re.IGNORECASE)
|
|
50
|
+
and all(part not in ("", ".", "..") for part in path.split("/"))
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _archive(data: bytes) -> dict[str, bytes]:
|
|
55
|
+
files: dict[str, bytes] = {}
|
|
56
|
+
names: set[str] = set()
|
|
57
|
+
total = 0
|
|
58
|
+
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
|
59
|
+
entries = archive.infolist()
|
|
60
|
+
if len(entries) > MAX_ARCHIVE_ENTRIES:
|
|
61
|
+
raise invalid_snapshot()
|
|
62
|
+
for entry in entries:
|
|
63
|
+
# Recover exact bytes even when the UTF-8 bit is absent. Do not use
|
|
64
|
+
# zipfile's CP437 fallback or its NUL-truncated filename for lookup.
|
|
65
|
+
raw_name = entry.orig_filename.encode("utf-8" if entry.flag_bits & 0x800 else "cp437")
|
|
66
|
+
name = raw_name.decode("utf-8", errors="strict")
|
|
67
|
+
directory = name.endswith("/")
|
|
68
|
+
kind = (entry.external_attr >> 16) & 0o170000
|
|
69
|
+
total += entry.file_size
|
|
70
|
+
if (
|
|
71
|
+
not safe_path(name[:-1] if directory else name)
|
|
72
|
+
or name in names
|
|
73
|
+
or kind not in (0, 0o040000 if directory else 0o100000)
|
|
74
|
+
or entry.flag_bits & 1
|
|
75
|
+
or entry.compress_type not in (0, 8)
|
|
76
|
+
or entry.file_size < 0
|
|
77
|
+
or total > MAX_SNAPSHOT_BYTES
|
|
78
|
+
or (directory and entry.file_size != 0)
|
|
79
|
+
):
|
|
80
|
+
raise invalid_snapshot()
|
|
81
|
+
names.add(name)
|
|
82
|
+
offset = entry.header_offset
|
|
83
|
+
if offset < 0 or offset + 30 > len(data):
|
|
84
|
+
raise invalid_snapshot()
|
|
85
|
+
signature, _, flags, method, _, _, _, compressed, decoded, name_length, extra_length = (
|
|
86
|
+
struct.unpack_from("<IHHHHHIIIHH", data, offset)
|
|
87
|
+
)
|
|
88
|
+
start = offset + 30 + name_length + extra_length
|
|
89
|
+
if (
|
|
90
|
+
signature != 0x04034B50
|
|
91
|
+
or flags != entry.flag_bits
|
|
92
|
+
or method != entry.compress_type
|
|
93
|
+
or data[offset + 30 : offset + 30 + name_length] != raw_name
|
|
94
|
+
or start + entry.compress_size > len(data)
|
|
95
|
+
or (
|
|
96
|
+
not flags & 8
|
|
97
|
+
and (compressed != entry.compress_size or decoded != entry.file_size)
|
|
98
|
+
)
|
|
99
|
+
):
|
|
100
|
+
raise invalid_snapshot()
|
|
101
|
+
payload = data[start : start + entry.compress_size]
|
|
102
|
+
if method == 8:
|
|
103
|
+
decoder = zlib.decompressobj(-15)
|
|
104
|
+
content = decoder.decompress(payload, entry.file_size + 1)
|
|
105
|
+
if not decoder.eof or decoder.unconsumed_tail or decoder.unused_data:
|
|
106
|
+
raise invalid_snapshot()
|
|
107
|
+
else:
|
|
108
|
+
content = payload
|
|
109
|
+
if len(content) != entry.file_size or zlib.crc32(content) != entry.CRC:
|
|
110
|
+
raise invalid_snapshot()
|
|
111
|
+
if not directory:
|
|
112
|
+
files[name] = content
|
|
113
|
+
return files
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def validate_snapshot(response: object) -> VerifiedSnapshot:
|
|
117
|
+
"""Return only immutable text and metadata; retain no caller-owned buffers."""
|
|
118
|
+
try:
|
|
119
|
+
if not isinstance(response, dict):
|
|
120
|
+
raise invalid_snapshot()
|
|
121
|
+
raw = response.get("bytes")
|
|
122
|
+
revision, etag, media = (
|
|
123
|
+
response.get("revision"),
|
|
124
|
+
response.get("etag"),
|
|
125
|
+
response.get("contentType"),
|
|
126
|
+
)
|
|
127
|
+
if (
|
|
128
|
+
response.get("status") != "snapshot"
|
|
129
|
+
or not isinstance(raw, (bytes, bytearray))
|
|
130
|
+
or len(raw) > MAX_SNAPSHOT_BYTES
|
|
131
|
+
or not isinstance(revision, str)
|
|
132
|
+
or not revision
|
|
133
|
+
or not isinstance(etag, str)
|
|
134
|
+
or re.fullmatch(r'"[a-f0-9]{64}"', etag) is None
|
|
135
|
+
or not isinstance(media, str)
|
|
136
|
+
or media.split(";", 1)[0].strip().lower() != "application/zip"
|
|
137
|
+
):
|
|
138
|
+
raise invalid_snapshot()
|
|
139
|
+
data = bytes(raw)
|
|
140
|
+
if '"' + hashlib.sha256(data).hexdigest() + '"' != etag:
|
|
141
|
+
raise invalid_snapshot()
|
|
142
|
+
archive = _archive(data)
|
|
143
|
+
manifest = json.loads(archive["manifest.json"].decode("utf-8", errors="strict"))
|
|
144
|
+
if not isinstance(manifest, dict):
|
|
145
|
+
raise invalid_snapshot()
|
|
146
|
+
version = manifest.get("schemaVersion")
|
|
147
|
+
if type(version) in (int, float) and version != 1:
|
|
148
|
+
raise LearnedSkillsError("UNSUPPORTED_SERVER", False)
|
|
149
|
+
if (
|
|
150
|
+
type(version) not in (int, float)
|
|
151
|
+
or version != 1
|
|
152
|
+
or manifest.get("revision") != revision
|
|
153
|
+
or not isinstance(manifest.get("skills"), list)
|
|
154
|
+
):
|
|
155
|
+
raise invalid_snapshot()
|
|
156
|
+
expected = {"manifest.json"}
|
|
157
|
+
skills: list[SnapshotSkill] = []
|
|
158
|
+
previous_name: bytes | None = None
|
|
159
|
+
for skill in manifest["skills"]:
|
|
160
|
+
if not isinstance(skill, dict):
|
|
161
|
+
raise invalid_snapshot()
|
|
162
|
+
name, description = skill.get("name"), skill.get("description")
|
|
163
|
+
if (
|
|
164
|
+
not isinstance(name, str)
|
|
165
|
+
or not safe_path(name)
|
|
166
|
+
or "/" in name
|
|
167
|
+
or not isinstance(description, str)
|
|
168
|
+
or not isinstance(skill.get("files"), list)
|
|
169
|
+
):
|
|
170
|
+
raise invalid_snapshot()
|
|
171
|
+
encoded_name = name.encode("utf-8", errors="strict")
|
|
172
|
+
if previous_name is not None and previous_name >= encoded_name:
|
|
173
|
+
raise invalid_snapshot()
|
|
174
|
+
previous_name = encoded_name
|
|
175
|
+
previous_path: bytes | None = None
|
|
176
|
+
files: list[SnapshotFile] = []
|
|
177
|
+
has_skill = False
|
|
178
|
+
for file in skill["files"]:
|
|
179
|
+
if not isinstance(file, dict):
|
|
180
|
+
raise invalid_snapshot()
|
|
181
|
+
path, size, digest = file.get("path"), file.get("size"), file.get("sha256")
|
|
182
|
+
if (
|
|
183
|
+
not isinstance(path, str)
|
|
184
|
+
or not safe_path(path)
|
|
185
|
+
or not isinstance(size, (int, float))
|
|
186
|
+
or isinstance(size, bool)
|
|
187
|
+
or size < 0
|
|
188
|
+
or size > MAX_SNAPSHOT_BYTES
|
|
189
|
+
or int(size) != size
|
|
190
|
+
or not isinstance(digest, str)
|
|
191
|
+
or re.fullmatch(r"[a-f0-9]{64}", digest) is None
|
|
192
|
+
):
|
|
193
|
+
raise invalid_snapshot()
|
|
194
|
+
encoded_path = path.encode("utf-8", errors="strict")
|
|
195
|
+
if previous_path is not None and previous_path >= encoded_path:
|
|
196
|
+
raise invalid_snapshot()
|
|
197
|
+
previous_path = encoded_path
|
|
198
|
+
full_path = name + "/" + path
|
|
199
|
+
if full_path in expected:
|
|
200
|
+
raise invalid_snapshot()
|
|
201
|
+
expected.add(full_path)
|
|
202
|
+
content = archive[full_path]
|
|
203
|
+
if len(content) != size or hashlib.sha256(content).hexdigest() != digest:
|
|
204
|
+
raise invalid_snapshot()
|
|
205
|
+
try:
|
|
206
|
+
text = content.decode("utf-8", errors="strict")
|
|
207
|
+
except UnicodeDecodeError:
|
|
208
|
+
text = None
|
|
209
|
+
if path == "SKILL.md":
|
|
210
|
+
if text is None:
|
|
211
|
+
raise invalid_snapshot()
|
|
212
|
+
has_skill = True
|
|
213
|
+
files.append(SnapshotFile(path, int(size), digest, text))
|
|
214
|
+
if not has_skill:
|
|
215
|
+
raise invalid_snapshot()
|
|
216
|
+
skills.append(SnapshotSkill(name, description, tuple(files)))
|
|
217
|
+
if expected != archive.keys():
|
|
218
|
+
raise invalid_snapshot()
|
|
219
|
+
return VerifiedSnapshot(revision, etag, tuple(skills))
|
|
220
|
+
except LearnedSkillsError:
|
|
221
|
+
raise
|
|
222
|
+
except Exception as error:
|
|
223
|
+
raise invalid_snapshot(error) from None
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Vendor shared private source into each artifact without a public core package."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from shutil import copyfile
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DeliveryBuildHook(BuildHookInterface):
|
|
11
|
+
def initialize(self, version: str, build_data: dict[str, Any]) -> None:
|
|
12
|
+
root = Path(self.root)
|
|
13
|
+
source = root / "_vendor" / "_delivery"
|
|
14
|
+
if not source.is_dir():
|
|
15
|
+
source = root.parent / "intelligence-delivery-python-core" / "src" / "_delivery"
|
|
16
|
+
if not (source / "registry.py").is_file():
|
|
17
|
+
raise RuntimeError("Private delivery source is missing from the source distribution")
|
|
18
|
+
target = (
|
|
19
|
+
"_vendor/_delivery"
|
|
20
|
+
if self.target_name == "sdist"
|
|
21
|
+
else "copilotkit_intelligence_adk/_delivery"
|
|
22
|
+
)
|
|
23
|
+
if version == "editable":
|
|
24
|
+
generated = root / "src" / "copilotkit_intelligence_adk" / "_delivery"
|
|
25
|
+
generated.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
for file in source.glob("*.py"):
|
|
27
|
+
copyfile(file, generated / file.name)
|
|
28
|
+
for file in source.glob("*.py"):
|
|
29
|
+
build_data.setdefault("force_include", {})[str(file)] = f"{target}/{file.name}"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27,<2"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "copilotkit-intelligence-adk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Automatic learned skill delivery for native ADK agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
dependencies = ["copilotkit-intelligence-runtime>=0.1.0,<0.2", "google-adk>=1.17,<2"]
|
|
14
|
+
|
|
15
|
+
[tool.uv.sources]
|
|
16
|
+
copilotkit-intelligence-runtime = { path = "../runtime-python", editable = true }
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = ["pytest>=8,<9", "pytest-asyncio>=0.25,<1", "ruff>=0.11,<1", "mypy>=1.15,<2"]
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/copilotkit_intelligence_adk"]
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.sdist]
|
|
25
|
+
include = ["src", "build_hook.py", "README.md", "LICENSE", "pyproject.toml"]
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.hooks.custom]
|
|
28
|
+
path = "build_hook.py"
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
asyncio_mode = "auto"
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
|
|
34
|
+
[tool.ruff]
|
|
35
|
+
line-length = 100
|
|
36
|
+
|
|
37
|
+
[tool.ruff.lint]
|
|
38
|
+
select = ["E4", "E7", "E9", "F", "I", "UP"]
|
|
39
|
+
|
|
40
|
+
[tool.mypy]
|
|
41
|
+
python_version = "3.11"
|
|
42
|
+
strict = true
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Automatic learned skill delivery for native ADK agents."""
|
|
2
|
+
|
|
3
|
+
from copilotkit_intelligence import LearnedSkillsError, LearnedSkillsErrorCode
|
|
4
|
+
|
|
5
|
+
from .skills import SkillRegistry, SkillToolset
|
|
6
|
+
|
|
7
|
+
__all__ = ["SkillRegistry", "SkillToolset", "LearnedSkillsError", "LearnedSkillsErrorCode"]
|
|
File without changes
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Native ADK registry and toolset with invocation-owned in-memory pins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import weakref
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from copilotkit_intelligence import Intelligence, LearnedSkillsError, LearnedSkillsErrorCode
|
|
10
|
+
from google.adk.agents.readonly_context import ReadonlyContext
|
|
11
|
+
from google.adk.models.llm_request import LlmRequest
|
|
12
|
+
from google.adk.sessions.session import Session
|
|
13
|
+
from google.adk.tools.base_tool import BaseTool
|
|
14
|
+
from google.adk.tools.base_toolset import BaseToolset
|
|
15
|
+
from google.adk.tools.tool_context import ToolContext
|
|
16
|
+
from google.genai import types
|
|
17
|
+
|
|
18
|
+
from ._delivery.registry import Registry, Status
|
|
19
|
+
from ._delivery.snapshot import VerifiedSnapshot
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _Pin:
|
|
23
|
+
def __init__(self, session: Session) -> None:
|
|
24
|
+
self.session = weakref.ref(session)
|
|
25
|
+
self.snapshot: VerifiedSnapshot | None = None
|
|
26
|
+
self.error: tuple[LearnedSkillsErrorCode, bool] | None = None
|
|
27
|
+
self.lock = asyncio.Lock()
|
|
28
|
+
|
|
29
|
+
async def resolve(self, registry: Registry) -> VerifiedSnapshot:
|
|
30
|
+
async with self.lock:
|
|
31
|
+
if self.error is not None:
|
|
32
|
+
raise LearnedSkillsError(*self.error)
|
|
33
|
+
if self.snapshot is None:
|
|
34
|
+
try:
|
|
35
|
+
self.snapshot = await registry.acquire_snapshot()
|
|
36
|
+
except LearnedSkillsError as error:
|
|
37
|
+
# Never retain a traceback: it can hold the session alive.
|
|
38
|
+
self.error = (error.code, error.retryable)
|
|
39
|
+
raise
|
|
40
|
+
return self.snapshot
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SkillRegistry:
|
|
44
|
+
"""Share one container registry across selected ADK agents and toolsets."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
client: Intelligence | None = None,
|
|
50
|
+
api_key: str | None = None,
|
|
51
|
+
api_url: str | None = None,
|
|
52
|
+
container_id: str | None = None,
|
|
53
|
+
revision: str | None = None,
|
|
54
|
+
freshness_window: float = 5,
|
|
55
|
+
request_timeout: float = 5,
|
|
56
|
+
debug: bool = False,
|
|
57
|
+
) -> None:
|
|
58
|
+
self._registry = Registry(
|
|
59
|
+
client=client,
|
|
60
|
+
api_key=api_key,
|
|
61
|
+
api_url=api_url,
|
|
62
|
+
container_id=container_id,
|
|
63
|
+
revision=revision,
|
|
64
|
+
freshness_window=freshness_window,
|
|
65
|
+
request_timeout=request_timeout,
|
|
66
|
+
debug=debug,
|
|
67
|
+
)
|
|
68
|
+
self._pins: dict[tuple[int, str], _Pin] = {}
|
|
69
|
+
self._closed = False
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def status(self) -> Status:
|
|
73
|
+
return self._registry.status
|
|
74
|
+
|
|
75
|
+
async def initialize(self) -> None:
|
|
76
|
+
await self._registry.initialize()
|
|
77
|
+
|
|
78
|
+
async def aclose(self) -> None:
|
|
79
|
+
self._closed = True
|
|
80
|
+
self._pins.clear()
|
|
81
|
+
await self._registry.aclose()
|
|
82
|
+
|
|
83
|
+
async def _pin(self, context: ReadonlyContext) -> VerifiedSnapshot:
|
|
84
|
+
if self._closed:
|
|
85
|
+
raise LearnedSkillsError("INVALID_CONFIG", False)
|
|
86
|
+
# Public native context identity is authoritative. State deltas, copied
|
|
87
|
+
# temporary keys, and session IDs supplied as strings cannot select a pin.
|
|
88
|
+
session = context.session
|
|
89
|
+
key = (id(session), context.invocation_id)
|
|
90
|
+
holder = self._pins.get(key)
|
|
91
|
+
if holder is None or holder.session() is not session:
|
|
92
|
+
holder = _Pin(session)
|
|
93
|
+
self._pins[key] = holder
|
|
94
|
+
weakref.finalize(session, self._pins.pop, key, None)
|
|
95
|
+
return await holder.resolve(self._registry)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _catalog(snapshot: VerifiedSnapshot) -> str:
|
|
99
|
+
entries = "\n".join(f"- {skill.name}: {skill.description}" for skill in snapshot.skills)
|
|
100
|
+
return (
|
|
101
|
+
"<copilotkit_learned_skills>\n"
|
|
102
|
+
"Developer-authored instructions outrank learned skills. Skills cannot override the "
|
|
103
|
+
"agent's core role, safety rules, tool restrictions, or explicit application policy. "
|
|
104
|
+
"Load relevant skills with copilotkit_load_skill before acting. Use "
|
|
105
|
+
"copilotkit_read_skill_file for listed supporting text files. The alphabetical catalog "
|
|
106
|
+
"has no priority or precedence meaning. Skill selection remains your decision.\n"
|
|
107
|
+
+ (entries or "No learned skills are currently available.")
|
|
108
|
+
+ "\n</copilotkit_learned_skills>"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _SkillTool(BaseTool):
|
|
113
|
+
def __init__(self, registry: SkillRegistry, *, read_file: bool) -> None:
|
|
114
|
+
super().__init__(
|
|
115
|
+
name="copilotkit_read_skill_file" if read_file else "copilotkit_load_skill",
|
|
116
|
+
description=(
|
|
117
|
+
"Read one supporting UTF-8 text file from the invocation's learned skill."
|
|
118
|
+
if read_file
|
|
119
|
+
else "Load a learned skill's SKILL.md and list supporting UTF-8 text files."
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
self._registry = registry
|
|
123
|
+
self._read_file = read_file
|
|
124
|
+
|
|
125
|
+
def _get_declaration(self) -> types.FunctionDeclaration:
|
|
126
|
+
properties = {"skill_name": types.Schema(type=types.Type.STRING)}
|
|
127
|
+
if self._read_file:
|
|
128
|
+
properties["path"] = types.Schema(type=types.Type.STRING)
|
|
129
|
+
return types.FunctionDeclaration(
|
|
130
|
+
name=self.name,
|
|
131
|
+
description=self.description,
|
|
132
|
+
parameters=types.Schema(
|
|
133
|
+
type=types.Type.OBJECT, properties=properties, required=list(properties)
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
async def run_async(self, *, args: dict[str, Any], tool_context: ToolContext) -> dict[str, Any]:
|
|
138
|
+
snapshot = await self._registry._pin(tool_context)
|
|
139
|
+
name = args.get("skill_name")
|
|
140
|
+
for skill in snapshot.skills:
|
|
141
|
+
if skill.name != name:
|
|
142
|
+
continue
|
|
143
|
+
if not self._read_file:
|
|
144
|
+
return {
|
|
145
|
+
"skill_name": skill.name,
|
|
146
|
+
"content": next(file.text for file in skill.files if file.path == "SKILL.md"),
|
|
147
|
+
"files": [
|
|
148
|
+
file.path
|
|
149
|
+
for file in skill.files
|
|
150
|
+
if file.path != "SKILL.md" and file.text is not None
|
|
151
|
+
],
|
|
152
|
+
}
|
|
153
|
+
path = args.get("path")
|
|
154
|
+
for file in skill.files:
|
|
155
|
+
if file.path == path and path != "SKILL.md" and file.text is not None:
|
|
156
|
+
return {"content": file.text}
|
|
157
|
+
return {"error": "The supporting text file is not in this invocation's snapshot."}
|
|
158
|
+
return {"error": "The skill is not in this invocation's snapshot."}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class SkillToolset(BaseToolset):
|
|
162
|
+
"""Attach to LlmAgent.tools; the application owns the supplied SkillRegistry."""
|
|
163
|
+
|
|
164
|
+
def __init__(self, registry: SkillRegistry) -> None:
|
|
165
|
+
super().__init__()
|
|
166
|
+
self._registry = registry
|
|
167
|
+
self._tools: list[BaseTool] = [
|
|
168
|
+
_SkillTool(registry, read_file=False),
|
|
169
|
+
_SkillTool(registry, read_file=True),
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
async def get_tools(self, readonly_context: ReadonlyContext | None = None) -> list[BaseTool]:
|
|
173
|
+
# ADK can suppress discovery failures. Authorization belongs in the
|
|
174
|
+
# request hook, which runs before model execution, not in discovery.
|
|
175
|
+
return list(self._tools)
|
|
176
|
+
|
|
177
|
+
async def process_llm_request(
|
|
178
|
+
self, *, tool_context: ToolContext, llm_request: LlmRequest
|
|
179
|
+
) -> None:
|
|
180
|
+
llm_request.append_instructions([_catalog(await self._registry._pin(tool_context))])
|
|
181
|
+
|
|
182
|
+
async def close(self) -> None:
|
|
183
|
+
# Runner/toolset ownership does not include a shared application registry.
|
|
184
|
+
pass
|