mirror of
https://github.com/google/adk-go.git
synced 2026-10-28 16:44:38 +08:00
* chore(lint): add misspell linter and perform US spelling sweep Rebased onto current main as a single commit, with the US spelling fixes extended to code that landed on main since, and folded one-time codespell cleanup for genuine typo coverage. * chore(lint): use US spelling in applyLiveConnLimits comment --------- Co-authored-by: wolo <wolo@google.com> Co-authored-by: Sasha Shynkaruk <oleksandrsh@google.com> Co-authored-by: Sasha <39381081+jjsasha63@users.noreply.github.com>
305 lines
11 KiB
Go
305 lines
11 KiB
Go
// Copyright 2026 Google LLC
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package workflow
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"iter"
|
|
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
"google.golang.org/genai"
|
|
|
|
"google.golang.org/adk/v2/agent"
|
|
internalcontext "google.golang.org/adk/v2/internal/context"
|
|
"google.golang.org/adk/v2/internal/llminternal"
|
|
"google.golang.org/adk/v2/session"
|
|
)
|
|
|
|
// AgentNode wraps a standard agent.Agent. Wrapped agents should emit their final output via
|
|
// Event.Output to be propagated to successor nodes
|
|
type AgentNode struct {
|
|
BaseNode
|
|
agent agent.Agent
|
|
}
|
|
|
|
// newAgentNodeWithSchemasTyped creates a new node wrapping an agent with explicitly provided schemas.
|
|
// If a schema is nil, it will be inferred from the corresponding generic type Input or Output.
|
|
func newAgentNodeWithSchemasTyped[Input, Output any](a agent.Agent, inputSchema, outputSchema *jsonschema.Schema, cfg NodeConfig) (*AgentNode, error) {
|
|
if a == nil {
|
|
return nil, fmt.Errorf("agent cannot be nil")
|
|
}
|
|
ischema, err := resolvedSchema[Input](inputSchema)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolving input schema for agent %q: %w", a.Name(), err)
|
|
}
|
|
oschema, err := resolvedSchema[Output](outputSchema)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolving output schema for agent %q: %w", a.Name(), err)
|
|
}
|
|
|
|
// The wrapped agent's Run already emits an invoke_agent span, so
|
|
// the scheduler must not add a redundant invoke_node wrapper —
|
|
// whether this node is activated by a static edge or delegated to
|
|
// via RunNode. Mirrors runner.newAgentNode.
|
|
cfg.EmitsOwnSpan = true
|
|
|
|
return &AgentNode{
|
|
BaseNode: NewBaseNodeWithSchemas(a.Name(), a.Description(), cfg, ischema, oschema),
|
|
agent: a,
|
|
}, nil
|
|
}
|
|
|
|
// NewAgentNodeWithSchemas is a convenience wrapper for NewAgentNodeWithSchemasTyped[any, any].
|
|
// It uses explicitly provided schemas for both input and output.
|
|
func NewAgentNodeWithSchemas(a agent.Agent, inputSchema, outputSchema *jsonschema.Schema, cfg NodeConfig) (*AgentNode, error) {
|
|
return newAgentNodeWithSchemasTyped[any, any](a, inputSchema, outputSchema, cfg)
|
|
}
|
|
|
|
// NewAgentNodeTyped creates a new node wrapping an agent using generics to
|
|
// automatically infer input and output schemas from the provided types.
|
|
func NewAgentNodeTyped[Input, Output any](a agent.Agent, cfg NodeConfig) (*AgentNode, error) {
|
|
return newAgentNodeWithSchemasTyped[Input, Output](a, nil, nil, cfg)
|
|
}
|
|
|
|
// NewAgentNode creates a new node wrapping an agent. Input and output schemas
|
|
// are inferred as `any`.
|
|
func NewAgentNode(a agent.Agent, cfg NodeConfig) (*AgentNode, error) {
|
|
return NewAgentNodeTyped[any, any](a, cfg)
|
|
}
|
|
|
|
// Run implements the Node interface.
|
|
func (n *AgentNode) Run(ctx agent.Context, input any) iter.Seq2[*session.Event, error] {
|
|
return func(yield func(*session.Event, error) bool) {
|
|
userContent, err := nodeInputToContent(input)
|
|
if err != nil {
|
|
yield(nil, err)
|
|
return
|
|
}
|
|
// Resolving the mode reads ctx, so a nil one is rejected before that
|
|
// rather than dereferenced further down. Exported, and RunLLMAgentAsNode
|
|
// — the other exported entry point that resolves a mode — rejects it the
|
|
// same way. Placed after nodeInputToContent, which never touches ctx, so
|
|
// an unmarshalable input still reports the marshaling error the merge
|
|
// base reported for it. Untyped nil only: a typed-nil agent.Context
|
|
// still panics, as it does everywhere else in these packages.
|
|
if ctx == nil {
|
|
yield(nil, fmt.Errorf("AgentNode.Run: nil context for agent %q", n.agent.Name()))
|
|
return
|
|
}
|
|
|
|
// A graph node is a one-shot placement: an agent that declares no
|
|
// mode runs single_turn here. Bound under this agent's own key — its
|
|
// name and its identity — so it cannot govern a peer this agent later
|
|
// transfers to, nor a same-named agent nested beneath it.
|
|
//
|
|
// The binding goes into the context this function passes DOWN, not back
|
|
// into ctx. Routing it through ctx.WithAgentContext would be the obvious
|
|
// shape and is a crash: that method returns nil for a tool context and
|
|
// for a callback context, both of which log and carry on, and the nil is
|
|
// dereferenced a few lines below. Neither wrapper reaches this function:
|
|
// SingleTurnTool does drive a node from inside a tool, but workflow.RunNode
|
|
// uses the caller's context only to fetch its SubScheduler, and the child
|
|
// context comes from the scheduler's own parent. Every symbol on that path
|
|
// is exported, so an out-of-tree caller can still arrive here with either.
|
|
// Guarding the nil and keeping the old ctx is not the fix either — the
|
|
// binding only reaches the request processors through the context handed
|
|
// downstream, so dropping it would run an undeclared agent as chat at a
|
|
// single_turn node.
|
|
bound := context.Context(ctx)
|
|
if llmA, ok := n.agent.(llminternal.Agent); ok && llmA != nil {
|
|
state := llminternal.Reveal(llmA)
|
|
mode := llminternal.ResolveMode(state.Mode, llminternal.ModeSingleTurn)
|
|
bound = llminternal.WithBoundMode(ctx, n.agent.Name(), state, mode)
|
|
}
|
|
|
|
// Use existing agent context instead of implementing a new one.
|
|
// Branch is inherited from ctx so the agent runs under the
|
|
// activation's branch; the scheduler assigns sub-branches at
|
|
// fan-out, and the LLM flow's history filter scopes events
|
|
// by branch prefix.
|
|
params := internalcontext.InvocationContextParams{
|
|
Artifacts: ctx.Artifacts(),
|
|
Memory: ctx.Memory(),
|
|
Session: ctx.Session(),
|
|
Branch: ctx.Branch(),
|
|
IsolationScope: ctx.IsolationScope(),
|
|
Agent: n.agent,
|
|
UserContent: userContent,
|
|
RunConfig: ctx.RunConfig(),
|
|
EndInvocation: ctx.Ended(),
|
|
InvocationID: ctx.InvocationID(),
|
|
}
|
|
agentCtx := internalcontext.NewInvocationContext(bound, params)
|
|
exCtx := agent.NewContext(agentCtx)
|
|
|
|
type NodeRunner interface {
|
|
RunNode(ctx agent.Context, nodeInput any) iter.Seq2[*session.Event, error]
|
|
}
|
|
|
|
var events iter.Seq2[*session.Event, error]
|
|
if runner, ok := n.agent.(NodeRunner); ok {
|
|
events = runner.RunNode(exCtx, input)
|
|
} else {
|
|
events = n.agent.Run(exCtx)
|
|
}
|
|
|
|
// Task-mode LlmAgents set their output via runTask, not model text.
|
|
synthesizeMode := true
|
|
if llmA, ok := n.agent.(llminternal.Agent); ok && llmA != nil {
|
|
synthesizeMode = llminternal.Reveal(llmA).Mode != llminternal.ModeTask
|
|
}
|
|
|
|
for event, err := range events {
|
|
if err != nil {
|
|
yield(nil, err)
|
|
return
|
|
}
|
|
|
|
// A composite agent yields one final response per sub-agent
|
|
// (each a distinct author); synthesizing all of them would
|
|
// trip the one-output-per-node rule.
|
|
if synthesizeMode && isOwnAgentEvent(event, n.agent.Name()) {
|
|
synthesizeAgentOutput(event)
|
|
}
|
|
|
|
// Tag the event for scope filtering (mirrors adk-python
|
|
// NodeRunner._enrich_event). The scheduler stamps delegated
|
|
// child events; this covers the direct agent-wrapper path.
|
|
if sc := ctx.IsolationScope(); sc != "" && event.IsolationScope == "" {
|
|
event.IsolationScope = sc
|
|
}
|
|
|
|
// The output schema (if any) is applied by the scheduler via
|
|
// ValidateOutput; synthesizeAgentOutput leaves the raw model
|
|
// text for defaultValidateOutput to project onto the schema.
|
|
if !yield(event, nil) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// synthesizeAgentOutput sets Event.Output from concatenated model
|
|
// text on final model responses so RunNode returns the agent's
|
|
// reply instead of the zero value. Empty model text yields an empty
|
|
// "" output (a value, not "no output"), matching adk-python and
|
|
// messageAsOutput; non-model events are left untouched.
|
|
//
|
|
// It also stamps NodeInfo.MessageAsOutput so readers (live and
|
|
// resume) know this event's output was derived from the model
|
|
// message, mirroring adk-python's process_llm_agent_output which
|
|
// sets event.output and node_info.message_as_output together.
|
|
//
|
|
// Long-running-tool events (e.g. a tool that called
|
|
// ctx.RequestConfirmation and is awaiting the user's reply) are
|
|
// excluded: IsFinalResponse() returns true for them so the flow
|
|
// loop terminates the round, but they represent a pause, not a
|
|
// completion. Treating them as MessageAsOutput would cache an
|
|
// empty "" as the agent's "output" and, on resume, short-circuit
|
|
// the re-run via collectNodeOutputs / WithRunID-replay — making
|
|
// the chat wrapper synthesize a bogus completion FR for what is
|
|
// still an open delegation.
|
|
func synthesizeAgentOutput(event *session.Event) {
|
|
if event == nil || event.Output != nil {
|
|
return
|
|
}
|
|
if !event.IsFinalResponse() {
|
|
return
|
|
}
|
|
if len(event.LongRunningToolIDs) > 0 {
|
|
return
|
|
}
|
|
if text, ok := messageText(event); ok {
|
|
event.Output = text
|
|
if event.NodeInfo == nil {
|
|
event.NodeInfo = &session.NodeInfo{}
|
|
}
|
|
event.NodeInfo.MessageAsOutput = true
|
|
}
|
|
}
|
|
|
|
// isOwnAgentEvent reports whether ev came from the node's own agent.
|
|
// Un-authored events count as own (agent.Run stamps the node agent's
|
|
// name); composite sub-agents keep their own author.
|
|
func isOwnAgentEvent(ev *session.Event, nodeAgentName string) bool {
|
|
if ev == nil {
|
|
return false
|
|
}
|
|
return ev.Author == "" || ev.Author == nodeAgentName
|
|
}
|
|
|
|
// messageText concatenates the non-thought model text of an event. ok
|
|
// is false when the event carries no model content, distinguishing it
|
|
// from a model message with empty text.
|
|
func messageText(event *session.Event) (text string, ok bool) {
|
|
if event == nil {
|
|
return "", false
|
|
}
|
|
content := event.LLMResponse.Content
|
|
if content == nil || content.Role != "model" {
|
|
return "", false
|
|
}
|
|
var b []byte
|
|
for _, p := range content.Parts {
|
|
if p == nil || p.Text == "" || p.Thought {
|
|
continue
|
|
}
|
|
b = append(b, p.Text...)
|
|
}
|
|
return string(b), true
|
|
}
|
|
|
|
// childEventOutput returns the output an event carries: its Output, or
|
|
// the model text when MessageAsOutput is set.
|
|
func childEventOutput(event *session.Event) (any, bool) {
|
|
if event.Output != nil {
|
|
return event.Output, true
|
|
}
|
|
if event.NodeInfo != nil && event.NodeInfo.MessageAsOutput {
|
|
if text, ok := messageText(event); ok {
|
|
return text, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func nodeInputToContent(input any) (*genai.Content, error) {
|
|
switch v := input.(type) {
|
|
case nil:
|
|
return nil, nil
|
|
case *genai.Content:
|
|
if v == nil {
|
|
return nil, nil
|
|
}
|
|
return &genai.Content{Role: "user", Parts: v.Parts}, nil
|
|
case string:
|
|
return &genai.Content{Role: "user", Parts: []*genai.Part{{Text: v}}}, nil
|
|
case json.Marshaler:
|
|
b, err := v.MarshalJSON()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshaling input: %w", err)
|
|
}
|
|
return &genai.Content{Role: "user", Parts: []*genai.Part{{Text: string(b)}}}, nil
|
|
default:
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshaling input to JSON: %w", err)
|
|
}
|
|
return &genai.Content{Role: "user", Parts: []*genai.Part{{Text: string(b)}}}, nil
|
|
}
|
|
}
|