Skip to content

Commit a549322

Browse files
authored
Merge branch 'main' into main
2 parents 19e045a + 8f1971e commit a549322

81 files changed

Lines changed: 8113 additions & 1319 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

DEVELOPMENT.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,13 @@ loaded by the Makefile (via `-include .env`) to inject environment variables
7272
when you run `make` targets:
7373

7474
```shell
75+
# Set your provider (supported: openAI, anthropic, azureOpenAI, gemini, ollama)
76+
KAGENT_DEFAULT_MODEL_PROVIDER=openAI
77+
78+
# Set the corresponding API key for your provider
7579
OPENAI_API_KEY=your-openai-api-key
80+
# ANTHROPIC_API_KEY=your-anthropic-api-key
81+
# GOOGLE_API_KEY=your-google-api-key
7682
```
7783

7884
1. Build images, load them into kind cluster and deploy everything using Helm:

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ push-test-agent: buildx-create build-kagent-adk
177177
kubectl apply --namespace kagent --context kind-$(KIND_CLUSTER_NAME) -f go/core/test/e2e/agents/kebab/agent.yaml
178178
$(DOCKER_BUILDER) build --push $(BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) -t $(DOCKER_REGISTRY)/poem-flow:latest -f python/samples/crewai/poem_flow/Dockerfile ./python
179179
$(DOCKER_BUILDER) build --push $(BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) -t $(DOCKER_REGISTRY)/basic-openai:latest -f python/samples/openai/basic_agent/Dockerfile ./python
180+
$(DOCKER_BUILDER) build --push $(BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) -t $(DOCKER_REGISTRY)/langgraph-currency:latest -f python/samples/langgraph/currency/Dockerfile ./python
180181

181182
.PHONY: push-test-skill
182183
push-test-skill: buildx-create

docs/architecture/a2a-subagents.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# A2A Subagents
2+
3+
Kagent allows users to add subagents (other agents running on Kagent or remotely) as tools to a main agent, connected via the A2A protocol. This feature is enabled by `KAgentRemoteA2ATool` (`python/packages/kagent-adk/src/kagent/adk/_remote_a2a_tool.py`), kagent's custom replacement for the upstream `AgentTool(RemoteA2aAgent(...))` pairing.
4+
5+
It directly manages the A2A conversation with a remote subagent and adds three things the upstream lacks: HITL propagation, live activity viewing, and user ID forwarding.
6+
7+
See [human-in-the-loop.md](human-in-the-loop.md) for HITL details.
8+
9+
---
10+
11+
## How it works
12+
13+
Each parent A2A request creates a fresh `Runner` and fresh tool instances. `KAgentRemoteA2ATool.__init__` generates a UUID (`_last_context_id`) that is used as the A2A `context_id` for every message sent to the subagent. On the subagent side, this `context_id` becomes the session ID.
14+
15+
`run_async` has two phases:
16+
17+
- **Phase 1** (normal call): sends the request to the subagent and handles the response — returning the result, pausing for HITL if the subagent returns `input_required`, or returning an error string.
18+
- **Phase 2** (HITL resume): reads the stored `task_id`/`context_id` from `tool_context.tool_confirmation.payload` and forwards the user's decision (approve / reject / batch / ask-user answers) to the subagent's pending task.
19+
20+
On success, `run_async` returns:
21+
```python
22+
{"result": str, "subagent_session_id": str} # normal
23+
{"result": str, "subagent_session_id": str,
24+
"kagent_usage_metadata": dict} # with usage
25+
{"status": "pending", "waiting_for": "subagent_approval", ...} # HITL pause
26+
```
27+
28+
`KAgentRemoteA2AToolset` is a thin `BaseToolset` wrapper whose only job is ensuring the owned `httpx.AsyncClient` is closed when the runner shuts down — ADK's cleanup path only discovers `BaseToolset` instances, not bare `BaseTool` instances.
29+
30+
---
31+
32+
## User ID and session tagging
33+
34+
`_SubagentInterceptor` is registered on the A2A client at construction time and injects two headers on every outgoing request:
35+
36+
| Header | Value | Purpose |
37+
|---|---|---|
38+
| `x-user-id` | parent session's user ID | Scopes the subagent DB session to the same user |
39+
| `x-kagent-source` | `"agent"` | Hides the session from the agent's session history sidebar |
40+
41+
> Interceptors must be passed to `ClientFactory.create(interceptors=[...])``A2AClient.add_request_middleware()` appends to a list that the transport never reads.
42+
43+
On the subagent side, `KAgentRequestContextBuilder` reads these headers and passes them through to `_prepare_session`, which calls `KAgentSessionService.create_session()` with `source="subagent"`. The Go layer stores this in a `Source` column and excludes such sessions from `ListSessionsForAgent`.
44+
45+
---
46+
47+
## Live activity viewing
48+
49+
The UI can show what a subagent is doing in a live panel before it finishes. This works because the session ID is known before the tool runs:
50+
51+
Before the run loop, `A2aAgentExecutor` builds a `{tool_name → session_id}` map from all tools implementing the `SubagentSessionProvider` protocol (`subagent_session_id` property). The event converter stamps this as `kagent_subagent_session_id` metadata on each `function_call` DataPart as soon as the LLM emits the call. The UI reads it immediately and begins polling `/api/sessions/{id}` every 2 seconds, rendering the subagent's events as a nested chat thread. Nesting is capped at depth 3.
52+
53+
The map is keyed by tool name because within one parent request, all calls to the same subagent tool intentionally share one `context_id` — giving the subagent conversation continuity across sequential invocations. A fresh `context_id` is generated on the next parent request when the runner rebuilds.
54+
55+
When sending session requests to Go backend, take note that:
56+
57+
| Session query | Includes subagent sessions? |
58+
|---|---|
59+
| `GET /api/sessions/agent/{ns}/{name}` | No — filtered by `source != 'agent'` |
60+
| `GET /api/sessions/{id}` | Yes |
61+
| `GET /api/sessions/{id}/tasks` | Yes |

go/api/database/models.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ func ParseMessages(messages []Event) ([]*protocol.Message, error) {
5757
return result, nil
5858
}
5959

60+
// SessionSource represents the origin of a session.
61+
type SessionSource string
62+
63+
const (
64+
// SessionSourceUser indicates the session was initiated by a user.
65+
SessionSourceUser SessionSource = "user"
66+
// SessionSourceAgent indicates the session was created by a parent agent's A2A call.
67+
SessionSourceAgent SessionSource = "agent"
68+
)
69+
6070
type Session struct {
6171
ID string `gorm:"primaryKey;not null" json:"id"`
6272
Name *string `gorm:"index" json:"name,omitempty"`
@@ -66,6 +76,9 @@ type Session struct {
6676
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
6777

6878
AgentID *string `gorm:"index" json:"agent_id"`
79+
// Source indicates how this session was created.
80+
// SessionSourceUser = user-initiated, SessionSourceAgent = created by a parent agent's A2A call.
81+
Source *SessionSource `gorm:"index" json:"source,omitempty"`
6982
}
7083

7184
type Task struct {

go/api/httpapi/types.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,10 @@ type AgentResponse struct {
100100

101101
// SessionRequest represents a session creation/update request
102102
type SessionRequest struct {
103-
AgentRef *string `json:"agent_ref,omitempty"`
104-
Name *string `json:"name,omitempty"`
105-
ID *string `json:"id,omitempty"`
103+
AgentRef *string `json:"agent_ref,omitempty"`
104+
Name *string `json:"name,omitempty"`
105+
ID *string `json:"id,omitempty"`
106+
Source *database.SessionSource `json:"source,omitempty"`
106107
}
107108

108109
// Run types

go/core/internal/controller/reconciler/mcp_server_reconciler_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ func TestReconcileKagentMCPServer_ErrorPropagation(t *testing.T) {
8585
WithObjects(tc.mcpServer).
8686
Build()
8787

88-
dbManager, err := database.NewManager(&database.Config{
88+
dbManager, err := database.NewManager(context.Background(), &database.Config{
8989
PostgresConfig: &database.PostgresConfig{
9090
URL: connStr,
9191
VectorEnabled: true,

go/core/internal/controller/translator/agent/adk_api_translator.go

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1584,10 +1584,17 @@ func validateSubPath(p string) error {
15841584

15851585
// skillsInitData holds the template data for the unified skills-init script.
15861586
type skillsInitData struct {
1587-
AuthMountPath string // "/git-auth" or "" (for git auth)
1588-
GitRefs []gitRefData // git repos to clone
1589-
OCIRefs []ociRefData // OCI images to pull
1590-
InsecureOCI bool // --insecure flag for krane
1587+
AuthMountPath string // "/git-auth" or "" (for git auth)
1588+
GitRefs []gitRefData // git repos to clone
1589+
OCIRefs []ociRefData // OCI images to pull
1590+
InsecureOCI bool // --insecure flag for krane
1591+
SSHHosts []sshHostData // extra hosts to add to known_hosts via ssh-keyscan
1592+
}
1593+
1594+
// sshHostData holds the host and optional port for an SSH known_hosts entry.
1595+
type sshHostData struct {
1596+
Host string // hostname or IP
1597+
Port string // port number, empty means default (22)
15911598
}
15921599

15931600
// gitRefData holds pre-computed fields for each git skill ref, used by the script template.
@@ -1651,6 +1658,32 @@ func prepareSkillsInitData(
16511658

16521659
if authSecretRef != nil {
16531660
data.AuthMountPath = "/git-auth"
1661+
seenHosts := make(map[string]bool)
1662+
hostPattern := regexp.MustCompile(`^[A-Za-z0-9\.\-:]+$`)
1663+
portPattern := regexp.MustCompile(`^[0-9]+$`)
1664+
for _, ref := range gitRefs {
1665+
u, err := url.Parse(ref.URL)
1666+
if err != nil || u.Scheme != "ssh" {
1667+
continue
1668+
}
1669+
host := u.Hostname()
1670+
if host == "" || !hostPattern.MatchString(host) {
1671+
continue
1672+
}
1673+
port := u.Port()
1674+
if port == "22" {
1675+
port = "" // 22 is the SSH default; omit to avoid -p flag
1676+
}
1677+
if port != "" && !portPattern.MatchString(port) {
1678+
continue
1679+
}
1680+
key := host + ":" + port
1681+
if seenHosts[key] {
1682+
continue
1683+
}
1684+
seenHosts[key] = true
1685+
data.SSHHosts = append(data.SSHHosts, sshHostData{Host: host, Port: port})
1686+
}
16541687
}
16551688

16561689
seen := make(map[string]bool)

go/core/internal/controller/translator/agent/git_skills_test.go

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package agent_test
22

33
import (
44
"context"
5+
"fmt"
56
"testing"
67

78
"github.com/stretchr/testify/assert"
@@ -44,13 +45,14 @@ func Test_AdkApiTranslator_Skills(t *testing.T) {
4445
name string
4546
agent *v1alpha2.Agent
4647
// assertions
47-
wantSkillsInit bool
48-
wantSkillsVolume bool
49-
wantContainsBranch string
50-
wantContainsCommit string
51-
wantContainsPath string
52-
wantContainsKrane bool
53-
wantAuthVolume bool
48+
wantSkillsInit bool
49+
wantSkillsVolume bool
50+
wantContainsBranch string
51+
wantContainsCommit string
52+
wantContainsPath string
53+
wantContainsKrane bool
54+
wantAuthVolume bool
55+
wantSSHKeyscanHosts []string // substrings expected in the ssh-keyscan lines
5456
}{
5557
{
5658
name: "no skills - no init containers",
@@ -215,6 +217,34 @@ func Test_AdkApiTranslator_Skills(t *testing.T) {
215217
wantSkillsVolume: true,
216218
wantAuthVolume: true,
217219
},
220+
{
221+
name: "git skills with SSH URL and auth secret scans custom host",
222+
agent: &v1alpha2.Agent{
223+
ObjectMeta: metav1.ObjectMeta{Name: "agent-ssh", Namespace: namespace},
224+
Spec: v1alpha2.AgentSpec{
225+
Type: v1alpha2.AgentType_Declarative,
226+
Declarative: &v1alpha2.DeclarativeAgentSpec{
227+
SystemMessage: "test",
228+
ModelConfig: modelName,
229+
},
230+
Skills: &v1alpha2.SkillForAgent{
231+
GitAuthSecretRef: &corev1.LocalObjectReference{
232+
Name: "gitea-ssh-credentials",
233+
},
234+
GitRefs: []v1alpha2.GitRepo{
235+
{
236+
URL: "ssh://git@gitea-ssh.gitea:22/gitops/ssh-skills-repo.git",
237+
Ref: "main",
238+
},
239+
},
240+
},
241+
},
242+
},
243+
wantSkillsInit: true,
244+
wantSkillsVolume: true,
245+
wantAuthVolume: true,
246+
wantSSHKeyscanHosts: []string{"gitea-ssh.gitea"},
247+
},
218248
{
219249
name: "git skill with custom name",
220250
agent: &v1alpha2.Agent{
@@ -358,7 +388,7 @@ func Test_AdkApiTranslator_Skills(t *testing.T) {
358388
for _, v := range deployment.Spec.Template.Spec.Volumes {
359389
if v.Secret != nil && v.Name == "git-auth" {
360390
hasAuthVolume = true
361-
assert.Equal(t, "github-token", v.Secret.SecretName, "auth volume should reference the correct secret")
391+
assert.Equal(t, tt.agent.Spec.Skills.GitAuthSecretRef.Name, v.Secret.SecretName, "auth volume should reference the correct secret")
362392
}
363393
}
364394
assert.True(t, hasAuthVolume, "git-auth volume should exist")
@@ -378,6 +408,16 @@ func Test_AdkApiTranslator_Skills(t *testing.T) {
378408
assert.Contains(t, script, "credential.helper")
379409
}
380410

411+
// Verify custom SSH hosts are scanned
412+
if len(tt.wantSSHKeyscanHosts) > 0 {
413+
require.NotNil(t, skillsInitContainer)
414+
script := skillsInitContainer.Command[2]
415+
for _, host := range tt.wantSSHKeyscanHosts {
416+
expected := fmt.Sprintf("ssh-keyscan %s", host)
417+
assert.Contains(t, script, expected, "script should ssh-keyscan custom host %q", host)
418+
}
419+
}
420+
381421
// Verify insecure flag for OCI skills
382422
if tt.agent.Spec.Skills != nil && tt.agent.Spec.Skills.InsecureSkipVerify {
383423
require.NotNil(t, skillsInitContainer)

go/core/internal/controller/translator/agent/skills-init.sh.tmpl

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ if [ -f "${_auth_mount}/ssh-privatekey" ]; then
99
cp "${_auth_mount}/ssh-privatekey" ~/.ssh/id_rsa
1010
chmod 600 ~/.ssh/id_rsa
1111
ssh-keyscan github.com gitlab.com bitbucket.org >> ~/.ssh/known_hosts
12+
{{- range .SSHHosts }}
13+
{{- if .Port }}
14+
ssh-keyscan -p {{ .Port }} {{ .Host }} >> ~/.ssh/known_hosts
15+
{{- else }}
16+
ssh-keyscan {{ .Host }} >> ~/.ssh/known_hosts
17+
{{- end }}
18+
{{- end }}
1219
elif [ -f "${_auth_mount}/token" ]; then
1320
git config --global credential.helper "!f() { echo username=x-access-token; echo password=\$(cat ${_auth_mount}/token); }; f"
1421
fi

go/core/internal/database/client.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,16 @@ func (c *clientImpl) ListTasksForSession(ctx context.Context, sessionID string)
162162
}
163163

164164
func (c *clientImpl) ListSessionsForAgent(ctx context.Context, agentID string, userID string) ([]dbpkg.Session, error) {
165-
return list[dbpkg.Session](c.db.WithContext(ctx),
166-
Clause{Key: "agent_id", Value: agentID},
167-
Clause{Key: "user_id", Value: userID})
165+
var sessions []dbpkg.Session
166+
err := c.db.WithContext(ctx).
167+
Where("agent_id = ? AND user_id = ?", agentID, userID).
168+
Where("source IS NULL OR source != ?", dbpkg.SessionSourceAgent).
169+
Order("created_at ASC").
170+
Find(&sessions).Error
171+
if err != nil {
172+
return nil, fmt.Errorf("failed to list sessions for agent: %w", err)
173+
}
174+
return sessions, nil
168175
}
169176

170177
// ListSessions lists all sessions for a user

0 commit comments

Comments
 (0)