Enhance JSAPI and Context Management for Sub-Agent Calls

- Implemented automatic disabling of SSE output for sub-agent calls to prevent message corruption and client disconnection.
- Introduced a `forceSkipOutput` method to ensure `skip.output` is set to true for all sub-agent requests.
- Updated the `Fork` method in the context to create independent Memory instances, preventing state sharing during concurrent executions.
- Enhanced documentation to clarify the behavior of SSE output and context management in batch operations.
This commit is contained in:
Max 2026-01-26 10:48:00 +08:00
parent bb853ad8e7
commit 4f132859a4
4 changed files with 111 additions and 4 deletions

View file

@ -27,8 +27,12 @@ func NewJSAPI(ctx *agentContext.Context) *JSAPI {
// Call executes a single agent call // Call executes a single agent call
// Usage: ctx.agent.Call("assistant-id", messages, options?) // Usage: ctx.agent.Call("assistant-id", messages, options?)
// Returns: { agent_id, response, content, error } // Returns: { agent_id, response, content, error }
// Note: SSE output is automatically disabled to prevent sub-agent from writing to
// the same SSE stream as the parent agent, which would cause client issues.
func (api *JSAPI) Call(agentID string, messages []interface{}, opts map[string]interface{}) interface{} { func (api *JSAPI) Call(agentID string, messages []interface{}, opts map[string]interface{}) interface{} {
req := api.buildRequest(agentID, messages, opts) req := api.buildRequest(agentID, messages, opts)
// Force skip.output = true for sub-agent calls
api.forceSkipOutput(req)
result := api.orchestrator.callAgent(req) result := api.orchestrator.callAgent(req)
return result return result
} }
@ -71,9 +75,12 @@ func (api *JSAPI) Race(requests []interface{}) []interface{} {
// ============================================================================ // ============================================================================
// CallWithHandler executes a single agent call with an OnMessage handler // CallWithHandler executes a single agent call with an OnMessage handler
// Note: SSE output is automatically disabled; use the handler callback to receive messages.
func (api *JSAPI) CallWithHandler(agentID string, messages []interface{}, opts map[string]interface{}, handler agentContext.OnMessageFunc) interface{} { func (api *JSAPI) CallWithHandler(agentID string, messages []interface{}, opts map[string]interface{}, handler agentContext.OnMessageFunc) interface{} {
req := api.buildRequest(agentID, messages, opts) req := api.buildRequest(agentID, messages, opts)
req.Handler = handler req.Handler = handler
// Force skip.output = true for sub-agent calls
api.forceSkipOutput(req)
result := api.orchestrator.callAgent(req) result := api.orchestrator.callAgent(req)
return result return result
} }
@ -99,8 +106,24 @@ func (api *JSAPI) RaceWithHandler(requests []interface{}, globalHandler agentCon
return api.convertResults(results) return api.convertResults(results)
} }
// forceSkipOutput ensures SSE output is disabled for sub-agent calls
// This prevents sub-agents from writing to the same SSE stream as the parent,
// which would cause client disconnection and message corruption.
// Users can use the onChunk callback to receive streaming messages if needed.
func (api *JSAPI) forceSkipOutput(req *Request) {
if req.Options == nil {
req.Options = &CallOptions{}
}
if req.Options.Skip == nil {
req.Options.Skip = &agentContext.Skip{}
}
req.Options.Skip.Output = true
}
// parseRequestsWithHandlers parses requests and attaches handlers // parseRequestsWithHandlers parses requests and attaches handlers
// It checks for per-request _handler fields and wraps globalHandler with agentID/index // It checks for per-request _handler fields and wraps globalHandler with agentID/index
// For all calls, this automatically sets skip.output = true to prevent sub-agents
// from writing to the same SSE stream as the parent, which would cause client issues.
func (api *JSAPI) parseRequestsWithHandlers(requests []interface{}, globalHandler agentContext.BatchOnMessageFunc) []*Request { func (api *JSAPI) parseRequestsWithHandlers(requests []interface{}, globalHandler agentContext.BatchOnMessageFunc) []*Request {
reqs := make([]*Request, 0, len(requests)) reqs := make([]*Request, 0, len(requests))
@ -130,6 +153,9 @@ func (api *JSAPI) parseRequestsWithHandlers(requests []interface{}, globalHandle
req := api.buildRequest(agentID, messages, opts) req := api.buildRequest(agentID, messages, opts)
// Force skip.output = true for all sub-agent calls
api.forceSkipOutput(req)
// Check for per-request handler first (takes precedence) // Check for per-request handler first (takes precedence)
if handler, ok := reqMap["_handler"].(agentContext.OnMessageFunc); ok && handler != nil { if handler, ok := reqMap["_handler"].(agentContext.OnMessageFunc); ok && handler != nil {
req.Handler = handler req.Handler = handler

View file

@ -2044,6 +2044,13 @@ Common message types:
The parallel methods allow calling multiple agents concurrently, similar to JavaScript Promise patterns. The parallel methods allow calling multiple agents concurrently, similar to JavaScript Promise patterns.
> **Important: SSE Output is Automatically Disabled**
>
> For all batch calls (`All`, `Any`, `Race`), SSE output is **automatically disabled** (`skip.output = true`).
> This prevents multiple agents from writing to the same SSE stream simultaneously, which would cause
> client disconnection and message corruption. Use the `onChunk` callback to receive streaming messages
> if needed.
#### `ctx.agent.All(requests, options?)` #### `ctx.agent.All(requests, options?)`
Executes all agent calls and waits for all to complete (like `Promise.all`). Executes all agent calls and waits for all to complete (like `Promise.all`).
@ -2064,6 +2071,7 @@ interface AgentRequest {
// Note: Per-request onChunk is NOT supported in batch calls. // Note: Per-request onChunk is NOT supported in batch calls.
// Use the global onChunk callback in the second argument instead. // Use the global onChunk callback in the second argument instead.
// Note: skip.output is automatically set to true for all batch calls.
``` ```
**Example:** **Example:**

View file

@ -133,17 +133,34 @@ func (ctx *Context) GetAuthorizedMap() map[string]interface{} {
} }
// Fork creates a child context for concurrent agent/LLM calls // Fork creates a child context for concurrent agent/LLM calls
// The forked context shares read-only resources (Memory, Authorized, Cache, Writer) // The forked context shares read-only resources (Authorized, Cache, Writer)
// but has its own independent Stack and Logger to avoid race conditions // but has its own independent Stack, Logger, and Memory.Context namespace
// to avoid race conditions and state sharing issues.
// //
// This is essential for batch operations (All/Any/Race) where multiple goroutines // This is essential for batch operations (All/Any/Race) where multiple goroutines
// need to execute concurrently without interfering with each other's Stack state. // need to execute concurrently without interfering with each other's state.
//
// Key behavior:
// - Memory.User, Memory.Team, Memory.Chat are shared (cross-request state)
// - Memory.Context is INDEPENDENT (request-scoped state, isolated per fork)
// //
// The forked context does NOT need to be released separately - the parent context // The forked context does NOT need to be released separately - the parent context
// manages shared resources. However, the child's Stack will be collected in parent's Stacks map. // manages shared resources. However, the child's Stack will be collected in parent's Stacks map.
func (ctx *Context) Fork() *Context { func (ctx *Context) Fork() *Context {
childID := generateContextID() childID := generateContextID()
// Fork memory with independent Context namespace
// This prevents parallel sub-agents from sharing ctx.memory.context state
var forkedMemory *memory.Memory
if ctx.Memory != nil {
var err error
forkedMemory, err = ctx.Memory.Fork(childID)
if err != nil {
// Fallback to shared memory if fork fails (log warning)
forkedMemory = ctx.Memory
}
}
child := &Context{ child := &Context{
// Inherit parent's standard context // Inherit parent's standard context
Context: ctx.Context, Context: ctx.Context,
@ -151,8 +168,10 @@ func (ctx *Context) Fork() *Context {
// New unique ID for this forked context // New unique ID for this forked context
ID: childID, ID: childID,
// Memory with independent Context namespace (see above)
Memory: forkedMemory,
// Share read-only/thread-safe resources with parent // Share read-only/thread-safe resources with parent
Memory: ctx.Memory, // Memory is designed to be shared
Cache: ctx.Cache, // Cache store is thread-safe Cache: ctx.Cache, // Cache store is thread-safe
Writer: ctx.Writer, // Output writer is thread-safe (output module handles concurrency) Writer: ctx.Writer, // Output writer is thread-safe (output module handles concurrency)
Authorized: ctx.Authorized, // Read-only auth info Authorized: ctx.Authorized, // Read-only auth info

View file

@ -183,3 +183,57 @@ func (m *Memory) Clear() {
m.Context.Clear() m.Context.Clear()
} }
} }
// Fork creates a new Memory instance with an independent Context namespace
// but sharing the User, Team, and Chat namespaces with the parent.
// This is used for parallel agent calls (ctx.agent.All/Any/Race) to prevent
// context state from being shared between concurrent sub-agent executions.
//
// The new Context namespace uses the provided newContextID.
// If newContextID is empty, returns a shallow copy with shared Context.
func (m *Memory) Fork(newContextID string) (*Memory, error) {
if m == nil {
return nil, nil
}
// If no new context ID provided, share everything (shallow copy)
if newContextID == "" {
return &Memory{
UserID: m.UserID,
TeamID: m.TeamID,
ChatID: m.ChatID,
ContextID: m.ContextID,
User: m.User,
Team: m.Team,
Chat: m.Chat,
Context: m.Context,
Config: m.Config,
}, nil
}
// Create new Memory with independent Context namespace
forked := &Memory{
UserID: m.UserID,
TeamID: m.TeamID,
ChatID: m.ChatID,
ContextID: newContextID,
User: m.User, // Shared
Team: m.Team, // Shared
Chat: m.Chat, // Shared
Context: nil, // Will be created below
Config: m.Config,
}
// Create new Context namespace with independent ID
storeID := ""
if m.Config != nil {
storeID = m.Config.Context
}
ns, err := newNamespace(SpaceContext, newContextID, storeID, DefaultContextTTL)
if err != nil {
return nil, fmt.Errorf("failed to create forked context namespace: %w", err)
}
forked.Context = ns
return forked, nil
}