Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions docs/architecture/configuration-and-compilation.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,10 +298,13 @@ download cache or fall back to an in-runtime OCI pull.

Package limits and path/symlink validation still apply. Build skill images from
scratch with just package contents; arbitrary ORAS artifact layouts are not
supported. Existing Git and S3 fetches remain runtime operations and retain
their golden-runtime networking limitations. The Python ADK does not yet consume
the `agent_plugins` runtime contract. BYO images must consume that contract to
load these skills; the Go ADK BYO fixture does so.
supported. Standalone Git and S3 sources use best-effort startup fetching.
The runtime retries missing skills before each task.
See [private Git skill sources](credential-injection.md#private-git-skill-sources)
for credential grants and networking constraints.
The Python ADK does not yet consume the `agent_plugins` runtime contract.
BYO images must consume that contract to load these skills; the Go ADK BYO
fixture does so.

### Runtime configuration

Expand Down
80 changes: 80 additions & 0 deletions docs/architecture/credential-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ overrides of these variables are rejected.
| Gemini API key | `x-goog-api-key: <key>` |
| Bedrock bearer token | `authorization: Bearer <token>` |
| RemoteMCPServer Secret-backed header | Configured header; Secret contains its full value |
| AgentTemplate `skills[].source.git.authorizationFrom` | `authorization`; Secret contains its full value |

Provider endpoint overrides determine the allowed HTTP(S) origin. Egress rules
and credential bindings both match its scheme, DNS name, and port. Different
Expand Down Expand Up @@ -120,6 +121,85 @@ This covers Go ADK MCP servers and models, OpenAI, Azure and Foundry memory
embeddings, and Python OpenAI and Anthropic models. Python MCP, Claude and
Codex still do not support passthrough.

## Private Git skill sources

A standalone skill can use a private HTTPS Git repository. Its Secret must be
in the AgentTemplate's namespace. The Secret owner grants access to one exact
HTTPS origin with the `kagent.dev/git-credential-origin` annotation.

```yaml
apiVersion: v1
kind: Secret
metadata:
name: skills-auth
annotations:
kagent.dev/git-credential-origin: https://github.com
stringData:
# GitHub: base64 of "x-access-token:<token>". Other forges may accept "Bearer <token>".
authorization: Basic eC1hY2Nlc3MtdG9rZW46PHRva2VuPg==
---
apiVersion: api.kagent.dev/v1alpha3
kind: AgentTemplate
metadata:
name: reviewer
spec:
skills:
- name: review
source:
git:
url: https://github.com/acme/private-skills
commit: 0123456789abcdef0123456789abcdef01234567
authorizationFrom:
name: skills-auth
key: authorization
```

The compiler checks the Secret, its non-empty key, and its authorized origin.
Missing references or an incorrect grant set `ResolvedRefs=False`.
Changes to the Secret trigger recompilation, including references from shared
child templates. HTTPS, DNS name, and port must match the grant.
Wildcards, IP addresses, URL credentials, and path-scoped grants are unsupported.

The gateway replaces `Authorization` only when the request includes that header.
The runtime sends a placeholder scoped to the repository URL through Git's
`http.<url>.extraHeader` setting. Authenticated fetches reject redirects.
Neither the Git configuration nor the runtime receives the Secret value.

The gateway binding covers the whole origin, not just the repository path.
Actor tools can also send an authorization header to that origin.
Use a read-only token restricted to the required repositories.
Separate permission to write Secrets from permission to write AgentTemplates.
Template writers cannot grant a Secret to another origin through a template.

The annotation is a compilation-time grant. Removing it prevents new successful
compilations; it does not revoke credentials from existing pinned Actors.
Use the platform's credential and egress controls for immediate revocation.
Do not give the shared golden Actor unrestricted access to namespace Secrets.

`authorizationFrom` is unsupported on `plugins[]`. SSH authentication and
token exchange are also unsupported.

### When skills are fetched

Go ADK, Claude, and Codex attempt standalone Git and S3 fetches at startup.
The startup attempt has a 30-second limit. A failed attempt logs a warning
and permits startup to continue.

Before each task, the runtime retries skills that are not installed.
A failed fetch fails the task with `skills_unavailable: …`.
A later task retries the missing skills. Installed skills need no further
network access while their files remain available.

OCI sources use worker-mounted image files. The runtime installs these skills
during preparation and fails preparation if a mounted source is invalid.
Plugin packages also remain eager.

Golden preparation can lack network access or credential bindings.
The startup fallback permits preparation without fetching remote skills.
If startup fetching succeeds, snapshots can contain private skill files.
Snapshots do not contain the injected credential. Apply snapshot access controls
that protect the private repository content.

## Deferred API fields

The source retains commented declarations for Harness and SandboxTemplate
Expand Down
6 changes: 4 additions & 2 deletions go/adk/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,15 @@ func run(logLevel, host, port, configDir string) error {
if port == "" && runtime.Port != nil {
port = fmt.Sprint(runtime.GetPort())
}
if err := config.MaterializeAgentPlugins(
ensureSkills, err := config.MaterializeAgentPlugins(
logging.IntoContext(context.Background(), logger), agentConfig,
config.AgentPluginPaths{
Packages: defaultPluginPackagesRoot,
Skills: config.DefaultSkillsRoot,
Data: defaultPluginDataRoot,
},
); err != nil {
)
if err != nil {
return fmt.Errorf("materialize Agent Plugins: %w", err)
}
logger.Info("loaded agent config", "config_dir", configDir)
Expand Down Expand Up @@ -188,6 +189,7 @@ func run(logLevel, host, port, configDir string) error {
Output: agentConfig.Output,
Flush: providers.ForceFlush,
AgentName: runtimeTelemetry.AgentName,
EnsureSkills: ensureSkills,
})
if err != nil {
return fmt.Errorf("create A2A executor: %w", err)
Expand Down
17 changes: 17 additions & 0 deletions go/adk/pkg/a2a/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ type KAgentExecutorConfig struct {
Output *configv1.ADKOutputConfig
Flush func(context.Context) error
AgentName string
// EnsureSkills installs downloaded standalone skills still missing after
// startup. A failure fails the task and the next task retries.
EnsureSkills func(context.Context) error
}

// KAgentExecutor keeps kagent's request/session glue around the upstream ADK
Expand All @@ -56,6 +59,7 @@ type KAgentExecutor struct {
flush func(context.Context) error
runningUsage runningUsage
agentName string
ensureSkills func(context.Context) error
}

type structuredOutput struct {
Expand Down Expand Up @@ -127,6 +131,7 @@ func NewKAgentExecutor(cfg KAgentExecutorConfig) (*KAgentExecutor, error) {
structuredOutputEnabled: output != nil,
flush: cfg.Flush,
agentName: cfg.AgentName,
ensureSkills: cfg.EnsureSkills,
}, nil
}

Expand Down Expand Up @@ -295,6 +300,18 @@ func (e *KAgentExecutor) Execute(ctx context.Context, reqCtx *a2asrv.ExecutorCon
"user_id", userID,
)

// Startup may lack egress for downloaded standalone skills; install any
// still missing before the agent runs, or fail the task visibly.
if e.ensureSkills != nil {
if err := e.ensureSkills(ctx); err != nil {
e.logger.ErrorContext(ctx, "standalone skills are unavailable", "task_id", reqCtx.TaskID, "error", err)
message := a2atype.NewMessageForTask(a2atype.MessageRoleAgent, reqCtx, a2atype.NewTextPart("skills_unavailable: "+err.Error()))
apia2a.SetTimelinePosition(message, time.Now())
yield(a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateFailed, message), nil)
return
}
}

// Run our own session management before upstream executor runs its prepareSession function.
// This ensures that we create a session that contains metadata like x-kagent-source,
// and the upstream executor will find this session already exists and skip creation.
Expand Down
45 changes: 45 additions & 0 deletions go/adk/pkg/a2a/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package a2a

import (
"context"
"errors"
"iter"
"log/slog"
"maps"
Expand Down Expand Up @@ -249,6 +250,50 @@ func TestKAgentExecutor_StampsStructuredOutputFailureMessage(t *testing.T) {
}
}

func TestKAgentExecutor_FailsTaskWhenSkillsAreUnavailable(t *testing.T) {
reqCtx := &a2asrv.ExecutorContext{
TaskID: "task-1", ContextID: "ctx-1",
Message: a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart("hello")),
}
builtin := &recordingExecutor{}
fetchErr := errors.New(`materialize skill "review": exit status 128`)
executor := &KAgentExecutor{
builtin: builtin, logger: slog.New(slog.DiscardHandler),
ensureSkills: func(context.Context) error { return fetchErr },
}
var events []a2atype.Event
for event, err := range executor.Execute(context.Background(), reqCtx) {
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
events = append(events, event)
}
if builtin.message != nil {
t.Fatal("the agent ran without its skills")
}
if len(events) != 1 {
t.Fatalf("events = %#v, want one failed status", events)
}
got, ok := events[0].(*a2atype.TaskStatusUpdateEvent)
if !ok || got.Status.State != a2atype.TaskStateFailed || got.Status.Message == nil {
t.Fatalf("status update = %#v, want failed status", events[0])
}
text, _ := got.Status.Message.Parts[0].Content.(a2atype.Text)
if want := "skills_unavailable: " + fetchErr.Error(); string(text) != want {
t.Fatalf("failure message = %q, want %q", text, want)
}

executor.ensureSkills = func(context.Context) error { return nil }
for _, err := range executor.Execute(context.Background(), reqCtx) {
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
}
if builtin.message == nil {
t.Fatal("the agent did not run once its skills were present")
}
}

func TestKAgentExecutor_PreservesContentBearingLastChunk(t *testing.T) {
reqCtx := &a2asrv.ExecutorContext{TaskID: "task-1", ContextID: "ctx-1"}
reqCtx.Message = a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart("hi"))
Expand Down
5 changes: 4 additions & 1 deletion go/adk/pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,10 @@ func createGoogleADKAgent(ctx context.Context, agentConfig *configv1.ADKAgentCon
if err != nil {
return nil, fmt.Errorf("failed to load skills: %w", err)
}
if len(skills) > 0 {
// Downloaded standalone skills may arrive after startup, before a later
// task; the skill toolset lists the directory on every request.
deferredSkills := len(agentConfig.GetAgentPlugins().GetSkills()) > 0
if len(skills) > 0 || deferredSkills {
skillsToolset, err := skilltoolset.New(ctx, skilltoolset.Config{Source: skillsSource})
if err != nil {
return nil, fmt.Errorf("failed to create skill toolset: %w", err)
Expand Down
53 changes: 53 additions & 0 deletions go/adk/pkg/agent/skills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,56 @@ func TestBuiltinToolsIndependentOfSkills(t *testing.T) {
}
}
}

// A downloaded standalone skill installed after startup must reach the model
// on the next request without rebuilding the agent.
func TestSkillToolsAdvertiseSkillsInstalledAfterStartup(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "test-key")
requests := make(chan string, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
Messages []struct {
Content json.RawMessage `json:"content"`
} `json:"messages"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode model request: %v", err)
}
var content string
for _, message := range request.Messages {
content += string(message.Content)
}
requests <- content
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"test","object":"chat.completion","model":"test","choices":[{"index":0,"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}`))
}))
t.Cleanup(server.Close)

skillsDir := t.TempDir()
model := &configv1.ADKModelConfig{Model: "gpt-4o", Provider: &configv1.ADKModelConfig_Openai{Openai: &configv1.ADKOpenAI{BaseUrl: server.URL}}}
cfg := &configv1.ADKConfig{Agent: &configv1.ADKAgentConfig{
Model: model, SkillsDirectory: skillsDir, Stream: new(false),
AgentPlugins: &configv1.ADKAgentPlugins{Skills: []*configv1.ADKSkill{{Name: "demo", Source: &configv1.ADKPluginSource{
Source: &configv1.ADKPluginSource_Git{Git: &configv1.ADKPluginGitSource{Url: "https://git.example.com/skills", Commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},
}}}},
}}
root, err := CreateGoogleADKAgent(t.Context(), cfg, "root", nil)
require.NoError(t, err)

require.NoError(t, os.Mkdir(filepath.Join(skillsDir, "demo"), 0755))
require.NoError(t, os.WriteFile(filepath.Join(skillsDir, "demo", "SKILL.md"), []byte("---\nname: demo\ndescription: Deferred demo skill.\n---\nRead files.\n"), 0644))
sessions := adksession.InMemoryService()
r, err := runner.New(runner.Config{AppName: "test", Agent: root, SessionService: sessions})
require.NoError(t, err)
sess, err := sessions.Create(t.Context(), &adksession.CreateRequest{AppName: "test", UserID: "user"})
require.NoError(t, err)
for _, err := range r.Run(t.Context(), "user", sess.Session.ID(), genai.NewContentFromText("Which skills do you have?", genai.RoleUser), adkagent.RunConfig{}) {
require.NoError(t, err)
}
select {
case content := <-requests:
require.Contains(t, content, "Deferred demo skill.")
case <-time.After(time.Second):
t.Fatal("model received no request")
}
}
70 changes: 44 additions & 26 deletions go/adk/pkg/config/agentplugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"context"
"errors"
"fmt"
"path/filepath"

Expand All @@ -22,36 +23,53 @@ type AgentPluginPaths struct {

// MaterializeAgentPlugins materializes plugins independently for each agent
// and adds their skills and MCP servers to the ADK runtime configuration.
func MaterializeAgentPlugins(ctx context.Context, agentConfig *configv1.ADKAgentConfig, paths AgentPluginPaths) error {
if agentConfig.AgentPlugins != nil {
materialization, err := agentplugins.Materialize(ctx, PluginResources(agentConfig.AgentPlugins), agentplugins.Paths{
Packages: paths.Packages,
Skills: paths.Skills,
})
if err != nil {
return fmt.Errorf("materialize agent plugins: %w", err)
}
if len(materialization.ClaudeFormatPluginRoots()) > 0 {
return fmt.Errorf("plugin is Claude-format; only the Agent Plugins format (plugin.json at the plugin root) is supported here")
// Downloaded standalone skills are fetched best-effort at startup; the
// returned function installs any still missing and must run before each task.
func MaterializeAgentPlugins(ctx context.Context, agentConfig *configv1.ADKAgentConfig, paths AgentPluginPaths) (func(context.Context) error, error) {
var materializations []agentplugins.Materialization
var visit func(*configv1.ADKAgentConfig, AgentPluginPaths) error
visit = func(agentConfig *configv1.ADKAgentConfig, paths AgentPluginPaths) error {
if agentConfig.AgentPlugins != nil {
materialization, err := agentplugins.Materialize(ctx, PluginResources(agentConfig.AgentPlugins), agentplugins.Paths{
Packages: paths.Packages,
Skills: paths.Skills,
})
if err != nil {
return fmt.Errorf("materialize agent plugins: %w", err)
}
if len(materialization.ClaudeFormatPluginRoots()) > 0 {
return fmt.Errorf("plugin is Claude-format; only the Agent Plugins format (plugin.json at the plugin root) is supported here")
}
mcpConfig, err := agentplugins.LoadMCP(ctx, materialization, paths.Data)
if err != nil {
return fmt.Errorf("load agent plugin MCP configuration: %w", err)
}
addMCPConfig(agentConfig, mcpConfig)
agentConfig.SkillsDirectory = materialization.SkillsDirectory
materializations = append(materializations, materialization)
}
mcpConfig, err := agentplugins.LoadMCP(ctx, materialization, paths.Data)
if err != nil {
return fmt.Errorf("load agent plugin MCP configuration: %w", err)
for i, child := range agentConfig.SubAgents {
childRoot := filepath.Join("subagents", fmt.Sprintf("%d", i))
if err := visit(child, AgentPluginPaths{
Packages: filepath.Join(paths.Packages, childRoot),
Skills: filepath.Join(paths.Skills, childRoot),
Data: filepath.Join(paths.Data, childRoot),
}); err != nil {
return fmt.Errorf("materialize sub-agent %q: %w", child.Name, err)
}
}
addMCPConfig(agentConfig, mcpConfig)
agentConfig.SkillsDirectory = materialization.SkillsDirectory
return nil
}
for i, child := range agentConfig.SubAgents {
childRoot := filepath.Join("subagents", fmt.Sprintf("%d", i))
if err := MaterializeAgentPlugins(ctx, child, AgentPluginPaths{
Packages: filepath.Join(paths.Packages, childRoot),
Skills: filepath.Join(paths.Skills, childRoot),
Data: filepath.Join(paths.Data, childRoot),
}); err != nil {
return fmt.Errorf("materialize sub-agent %q: %w", child.Name, err)
}
if err := visit(agentConfig, paths); err != nil {
return nil, err
}
return nil
return func(ctx context.Context) error {
var errs []error
for _, materialization := range materializations {
errs = append(errs, materialization.EnsureSkills(ctx))
}
return errors.Join(errs...)
}, nil
}

func addMCPConfig(agentConfig *configv1.ADKAgentConfig, mcpConfig agentplugins.MCPConfig) {
Expand Down
Loading