Refactor Agent Response Handling to Use Structured Response Types

- Updated the agent's Stream and response processing methods to return and handle *context.Response directly, eliminating the need for type assertions.
- Simplified test cases by removing unnecessary type conversions and directly accessing response fields.
- Enhanced the extraction of data from Next hook responses, ensuring more robust handling of custom data structures.
- Improved overall code readability and maintainability by streamlining response handling logic across various components.
This commit is contained in:
Max 2025-12-18 17:32:49 +08:00
parent 01bf7dce58
commit 637a0c9cbd
17 changed files with 243 additions and 335 deletions

View file

@ -16,7 +16,7 @@ import (
// Stream stream the agent // Stream stream the agent
// handler is optional, if not provided, a default handler will be used // handler is optional, if not provided, a default handler will be used
func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Message, options ...*context.Options) (interface{}, error) { func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Message, options ...*context.Options) (*context.Response, error) {
// Update logger with assistant ID and start logging // Update logger with assistant ID and start logging
ctx.Logger.SetAssistantID(ast.ID) ctx.Logger.SetAssistantID(ast.ID)
@ -376,7 +376,7 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
// ================================================ // ================================================
// Execute Next Hook and Process Response // Execute Next Hook and Process Response
// ================================================ // ================================================
var finalResponse interface{} var finalResponse *context.Response
var nextResponse *context.NextHookResponse = nil var nextResponse *context.NextHookResponse = nil
if ast.HookScript != nil { if ast.HookScript != nil {

View file

@ -56,19 +56,18 @@ func TestAgentNextStandard(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, response) assert.NotNil(t, response)
resp := response.(*context.Response) assert.NotNil(t, response.Completion)
assert.NotNil(t, resp.Completion) assert.Nil(t, response.Next)
assert.Nil(t, resp.Next)
// Verify response structure // Verify response structure
assert.Equal(t, "tests.realworld-next", resp.AssistantID) assert.Equal(t, "tests.realworld-next", response.AssistantID)
assert.NotEmpty(t, resp.ContextID) assert.NotEmpty(t, response.ContextID)
assert.NotEmpty(t, resp.RequestID) assert.NotEmpty(t, response.RequestID)
assert.NotEmpty(t, resp.TraceID) assert.NotEmpty(t, response.TraceID)
assert.NotEmpty(t, resp.ChatID) assert.NotEmpty(t, response.ChatID)
// Verify completion has content // Verify completion has content
assert.NotNil(t, resp.Completion.Content) assert.NotNil(t, response.Completion.Content)
t.Log("✓ Standard response test passed") t.Log("✓ Standard response test passed")
} }
@ -94,19 +93,18 @@ func TestAgentNextCustomData(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, response) assert.NotNil(t, response)
resp := response.(*context.Response) assert.NotNil(t, response.Completion)
assert.NotNil(t, resp.Completion) assert.NotNil(t, response.Next)
assert.NotNil(t, resp.Next)
// Verify response structure // Verify response structure
assert.Equal(t, "tests.realworld-next", resp.AssistantID) assert.Equal(t, "tests.realworld-next", response.AssistantID)
assert.NotEmpty(t, resp.ContextID) assert.NotEmpty(t, response.ContextID)
assert.NotEmpty(t, resp.RequestID) assert.NotEmpty(t, response.RequestID)
assert.NotEmpty(t, resp.TraceID) assert.NotEmpty(t, response.TraceID)
// Verify custom data structure (from scenarioCustomData) // Verify custom data structure (from scenarioCustomData)
// resp.Next contains the "data" field value from NextHookResponse // response.Next contains the "data" field value from NextHookResponse
nextData, ok := resp.Next.(map[string]interface{}) nextData, ok := response.Next.(map[string]interface{})
assert.True(t, ok, "Next should be a map") assert.True(t, ok, "Next should be a map")
assert.Equal(t, "custom_response", nextData["type"]) assert.Equal(t, "custom_response", nextData["type"])
assert.Equal(t, "This is a custom response from Next Hook", nextData["message"]) assert.Equal(t, "This is a custom response from Next Hook", nextData["message"])
@ -137,21 +135,19 @@ func TestAgentNextDelegate(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, response) assert.NotNil(t, response)
resp := response.(*context.Response)
// Verify response structure // Verify response structure
assert.NotEmpty(t, resp.AssistantID) assert.NotEmpty(t, response.AssistantID)
assert.NotEmpty(t, resp.ContextID) assert.NotEmpty(t, response.ContextID)
assert.NotEmpty(t, resp.RequestID) assert.NotEmpty(t, response.RequestID)
assert.NotEmpty(t, resp.TraceID) assert.NotEmpty(t, response.TraceID)
// Verify completion (delegated agent should have returned completion) // Verify completion (delegated agent should have returned completion)
assert.NotNil(t, resp.Completion) assert.NotNil(t, response.Completion)
assert.NotNil(t, resp.Completion.Content) assert.NotNil(t, response.Completion.Content)
// Next should be from the delegated agent // Next should be from the delegated agent
// If delegated agent also has Next hook, it will be present // If delegated agent also has Next hook, it will be present
t.Logf("✓ Delegation test passed (delegated to: %s)", resp.AssistantID) t.Logf("✓ Delegation test passed (delegated to: %s)", response.AssistantID)
} }
// TestAgentNextConditional tests agent with conditional logic in Next Hook // TestAgentNextConditional tests agent with conditional logic in Next Hook
@ -177,18 +173,17 @@ func TestAgentNextConditional(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, response) assert.NotNil(t, response)
resp := response.(*context.Response) assert.NotNil(t, response.Next)
assert.NotNil(t, resp.Next)
// Verify response structure // Verify response structure
assert.Equal(t, "tests.realworld-next", resp.AssistantID) assert.Equal(t, "tests.realworld-next", response.AssistantID)
assert.NotEmpty(t, resp.ContextID) assert.NotEmpty(t, response.ContextID)
assert.NotEmpty(t, resp.RequestID) assert.NotEmpty(t, response.RequestID)
assert.NotEmpty(t, resp.TraceID) assert.NotEmpty(t, response.TraceID)
// Verify conditional response structure (from scenarioConditional) // Verify conditional response structure (from scenarioConditional)
// resp.Next contains the "data" field value from NextHookResponse // response.Next contains the "data" field value from NextHookResponse
nextData, ok := resp.Next.(map[string]interface{}) nextData, ok := response.Next.(map[string]interface{})
assert.True(t, ok, "Next should be a map") assert.True(t, ok, "Next should be a map")
assert.Equal(t, "Conditional analysis complete", nextData["message"]) assert.Equal(t, "Conditional analysis complete", nextData["message"])
assert.Contains(t, nextData, "action") assert.Contains(t, nextData, "action")
@ -224,19 +219,18 @@ func TestAgentWithoutNextHook(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, response) assert.NotNil(t, response)
resp := response.(*context.Response) assert.Nil(t, response.Next)
assert.Nil(t, resp.Next)
// Verify response structure // Verify response structure
assert.Equal(t, "tests.create", resp.AssistantID) assert.Equal(t, "tests.create", response.AssistantID)
assert.NotEmpty(t, resp.ContextID) assert.NotEmpty(t, response.ContextID)
assert.NotEmpty(t, resp.RequestID) assert.NotEmpty(t, response.RequestID)
assert.NotEmpty(t, resp.TraceID) assert.NotEmpty(t, response.TraceID)
assert.NotEmpty(t, resp.ChatID) assert.NotEmpty(t, response.ChatID)
// Verify completion // Verify completion
assert.NotNil(t, resp.Completion) assert.NotNil(t, response.Completion)
assert.NotNil(t, resp.Completion.Content) assert.NotNil(t, response.Completion.Content)
t.Log("✓ No Next Hook test passed") t.Log("✓ No Next Hook test passed")
} }

View file

@ -51,7 +51,7 @@ type agentCallerWrapper struct {
ast *Assistant ast *Assistant
} }
func (w *agentCallerWrapper) Stream(ctx *agentContext.Context, messages []agentContext.Message, options ...*agentContext.Options) (interface{}, error) { func (w *agentCallerWrapper) Stream(ctx *agentContext.Context, messages []agentContext.Message, options ...*agentContext.Options) (*agentContext.Response, error) {
return w.ast.Stream(ctx, messages, options...) return w.ast.Stream(ctx, messages, options...)
} }

View file

@ -8,7 +8,7 @@ import (
) )
// processNextResponse processes the Next hook's response and handles agent delegation or custom data // processNextResponse processes the Next hook's response and handles agent delegation or custom data
func (ast *Assistant) processNextResponse(npc *NextProcessContext) (interface{}, error) { func (ast *Assistant) processNextResponse(npc *NextProcessContext) (*agentContext.Response, error) {
// If no Next hook response, return standard response // If no Next hook response, return standard response
if npc.NextResponse == nil { if npc.NextResponse == nil {
return ast.buildStandardResponse(npc), nil return ast.buildStandardResponse(npc), nil
@ -42,7 +42,7 @@ func (ast *Assistant) handleDelegation(
ctx *agentContext.Context, ctx *agentContext.Context,
delegate *agentContext.DelegateConfig, delegate *agentContext.DelegateConfig,
streamHandler func(message.StreamChunkType, []byte) int, streamHandler func(message.StreamChunkType, []byte) int,
) (interface{}, error) { ) (*agentContext.Response, error) {
// Load the target assistant // Load the target assistant
targetAssistant, err := Get(delegate.AgentID) targetAssistant, err := Get(delegate.AgentID)
if err != nil { if err != nil {
@ -62,7 +62,7 @@ func (ast *Assistant) handleDelegation(
} }
// buildStandardResponse builds the standard agent response when no custom Next hook processing is needed // buildStandardResponse builds the standard agent response when no custom Next hook processing is needed
func (ast *Assistant) buildStandardResponse(npc *NextProcessContext) interface{} { func (ast *Assistant) buildStandardResponse(npc *NextProcessContext) *agentContext.Response {
return &agentContext.Response{ return &agentContext.Response{
ContextID: npc.Context.ID, ContextID: npc.Context.ID,
RequestID: npc.Context.RequestID(), RequestID: npc.Context.RequestID(),

View file

@ -98,39 +98,37 @@ func (ast *Assistant) checkSearchIntent(ctx *context.Context, messages []context
// Parse the result // Parse the result
// Next hook returns {data: {need_search: bool, search_types: [], confidence: float}} // Next hook returns {data: {need_search: bool, search_types: [], confidence: float}}
if response, ok := result.(*context.Response); ok { // First try to get from Next hook response
// First try to get from Next hook response if result.Next != nil {
if response.Next != nil { if nextData, ok := result.Next.(map[string]interface{}); ok {
if nextData, ok := response.Next.(map[string]interface{}); ok { // Check for data field (from Next hook's {data: result})
// Check for data field (from Next hook's {data: result}) var intentData map[string]interface{}
var intentData map[string]interface{} if data, ok := nextData["data"].(map[string]interface{}); ok {
if data, ok := nextData["data"].(map[string]interface{}); ok { intentData = data
intentData = data } else {
} else { intentData = nextData
intentData = nextData }
}
if needSearch, ok := intentData["need_search"].(bool); ok { if needSearch, ok := intentData["need_search"].(bool); ok {
reason, _ := intentData["reason"].(string) reason, _ := intentData["reason"].(string)
ctx.Logger.Debug("Search intent (from Next): need_search=%v, reason=%s", needSearch, reason) ctx.Logger.Debug("Search intent (from Next): need_search=%v, reason=%s", needSearch, reason)
ast.sendIntentDone(ctx, loadingID, needSearch, reason) ast.sendIntentDone(ctx, loadingID, needSearch, reason)
return needSearch return needSearch
}
} }
} }
}
// Fallback: parse from Completion.Content if Next hook didn't process // Fallback: parse from Completion.Content if Next hook didn't process
if response.Completion != nil { if result.Completion != nil {
content, ok := response.Completion.Content.(string) content, ok := result.Completion.Content.(string)
if !ok || content == "" { if !ok || content == "" {
ast.sendIntentDone(ctx, loadingID, true, "") ast.sendIntentDone(ctx, loadingID, true, "")
return true return true
}
needSearch, reason := parseNeedSearchFromContent(content)
ctx.Logger.Debug("Search intent (from Content): need_search=%v, reason=%s", needSearch, reason)
ast.sendIntentDone(ctx, loadingID, needSearch, reason)
return needSearch
} }
needSearch, reason := parseNeedSearchFromContent(content)
ctx.Logger.Debug("Search intent (from Content): need_search=%v, reason=%s", needSearch, reason)
ast.sendIntentDone(ctx, loadingID, needSearch, reason)
return needSearch
} }
// Default: proceed with search if we can't parse the result // Default: proceed with search if we can't parse the result

View file

@ -77,8 +77,7 @@ func TestSearchAutoDisabled(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, response) require.NotNil(t, response)
resp := response.(*context.Response) assert.NotNil(t, response.Completion, "should have completion")
assert.NotNil(t, resp.Completion, "should have completion")
t.Logf("✓ Stream executed without search (disabled)") t.Logf("✓ Stream executed without search (disabled)")
}) })
} }

View file

@ -118,8 +118,7 @@ func TestSearchAutoFull(t *testing.T) {
} }
require.NotNil(t, response) require.NotNil(t, response)
resp := response.(*context.Response) assert.NotNil(t, response.Completion, "should have completion")
assert.NotNil(t, resp.Completion, "should have completion")
t.Logf("✓ Stream executed with full search config (Web + KB + DB)") t.Logf("✓ Stream executed with full search config (Web + KB + DB)")
}) })
} }

View file

@ -101,8 +101,7 @@ func TestSearchAutoHookDisable(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, response) require.NotNil(t, response)
resp := response.(*context.Response) assert.NotNil(t, response.Completion, "should have completion")
assert.NotNil(t, resp.Completion, "should have completion")
t.Logf("✓ Stream executed with hook disabling search") t.Logf("✓ Stream executed with hook disabling search")
}) })
} }

View file

@ -87,8 +87,7 @@ func TestSearchAutoKeyword(t *testing.T) {
} }
require.NotNil(t, response) require.NotNil(t, response)
resp := response.(*context.Response) assert.NotNil(t, response.Completion, "should have completion")
assert.NotNil(t, resp.Completion, "should have completion")
t.Logf("✓ Stream with keyword extraction executed successfully") t.Logf("✓ Stream with keyword extraction executed successfully")
}) })
@ -127,8 +126,7 @@ func TestSearchAutoKeyword(t *testing.T) {
} }
require.NotNil(t, response) require.NotNil(t, response)
resp := response.(*context.Response) assert.NotNil(t, response.Completion, "should have completion")
assert.NotNil(t, resp.Completion, "should have completion")
t.Logf("✓ Stream with Skip.Keyword executed successfully") t.Logf("✓ Stream with Skip.Keyword executed successfully")
}) })
} }
@ -179,8 +177,7 @@ func TestSearchAutoKeywordNotConfigured(t *testing.T) {
} }
require.NotNil(t, response) require.NotNil(t, response)
resp := response.(*context.Response) assert.NotNil(t, response.Completion, "should have completion")
assert.NotNil(t, resp.Completion, "should have completion")
t.Logf("✓ Stream without keyword config executed successfully") t.Logf("✓ Stream without keyword config executed successfully")
}) })
} }

View file

@ -96,8 +96,7 @@ func TestSearchAutoWeb(t *testing.T) {
} }
require.NotNil(t, response) require.NotNil(t, response)
resp := response.(*context.Response) assert.NotNil(t, response.Completion, "should have completion")
assert.NotNil(t, resp.Completion, "should have completion")
t.Logf("✓ Stream executed successfully with auto search") t.Logf("✓ Stream executed successfully with auto search")
}) })
} }

View file

@ -9,7 +9,7 @@ import (
// AgentCaller interface for calling agents (to avoid circular dependency) // AgentCaller interface for calling agents (to avoid circular dependency)
// Used by content handlers (vision, audio, etc.) and search handlers (agent mode) // Used by content handlers (vision, audio, etc.) and search handlers (agent mode)
type AgentCaller interface { type AgentCaller interface {
Stream(ctx *agentContext.Context, messages []agentContext.Message, options ...*agentContext.Options) (interface{}, error) Stream(ctx *agentContext.Context, messages []agentContext.Message, options ...*agentContext.Options) (*agentContext.Response, error)
} }
// AgentGetterFunc is a function type that gets an agent by ID // AgentGetterFunc is a function type that gets an agent by ID

View file

@ -42,10 +42,10 @@ func CallAgent(ctx *agentContext.Context, agentID string, message agentContext.M
// Extract text from agent response // Extract text from agent response
// Two formats are supported: // Two formats are supported:
// 1. Custom Hook response (from Next hook) // 1. Custom Hook response (from Next hook) - response.Next
// 2. Standard Agent Stream response (LLM completion) // 2. Standard Agent Stream response (LLM completion) - response.Completion
return extractTextFromAgentResponse(response) return extractTextFromResponse(response)
} }
// CallAgentWithFileInfo calls an agent to process content with file metadata // CallAgentWithFileInfo calls an agent to process content with file metadata
@ -120,116 +120,63 @@ func CallAgentWithFileInfo(ctx *agentContext.Context, agentID string, message ag
return CallAgent(ctx, agentID, message) return CallAgent(ctx, agentID, message)
} }
// extractTextFromAgentResponse extracts text from agent response // extractTextFromResponse extracts text from agent response
// Handles two main response formats from agent.Stream(): // Now that agent.Stream() returns *agentContext.Response directly,
// // we can access fields without type assertions or JSON conversion.
// 1. Standard Response (No Next Hook or Next Hook returns nil):
// Structure: { completion: { content: "text" | [...ContentPart] } }
// Action: Extract text from completion.content field
//
// 2. Next Hook Response with Custom Data:
// Structure: { next: <any data from Next hook> }
// Action:
// - If next is string → return directly
// - If next is map/object → JSON stringify and return
// - This preserves the complete custom data structure from the hook
// //
// Priority: // Priority:
// 1. Check for "next" field (custom hook data) → return complete data // 1. Check response.Next (custom hook data) → return complete data
// 2. Check for "completion" field (standard LLM response) → extract text only // 2. Check response.Completion (standard LLM response) → extract text only
// 3. Fallback to direct string or JSON stringify func extractTextFromResponse(response *agentContext.Response) (string, error) {
func extractTextFromAgentResponse(response interface{}) (string, error) {
if response == nil { if response == nil {
return "", fmt.Errorf("agent returned nil response") return "", fmt.Errorf("agent returned nil response")
} }
// First, try to convert to map if it's a struct // Priority 1: Check Next field (custom hook data)
// agent.Stream() may return *agentContext.Response which needs to be converted // If Next hook returns custom data, return the complete structure
var responseMap map[string]interface{} if response.Next != nil {
// Check if it's already a map
if rm, ok := response.(map[string]interface{}); ok {
responseMap = rm
} else {
// Try to marshal and unmarshal to convert struct to map
jsonBytes, err := jsoniter.Marshal(response)
if err != nil {
// If it's a plain string, return directly
if responseStr, ok := response.(string); ok {
return responseStr, nil
}
return "", fmt.Errorf("failed to serialize agent response: %w", err)
}
// Unmarshal to map
if err := jsoniter.Unmarshal(jsonBytes, &responseMap); err != nil {
// If unmarshal fails, return the JSON string
return string(jsonBytes), nil
}
}
// Priority 1: Check for "next" field (custom hook data)
// If Next hook returns custom data, it's stored in the "next" field
// Return the complete custom data structure (preserve hook's intent)
if next, hasNext := responseMap["next"]; hasNext && next != nil {
// If next is a string, return directly // If next is a string, return directly
if nextStr, ok := next.(string); ok { if nextStr, ok := response.Next.(string); ok {
return nextStr, nil return nextStr, nil
} }
// Otherwise, JSON stringify to preserve complete structure // Otherwise, JSON stringify to preserve complete structure
jsonBytes, err := jsoniter.Marshal(next) jsonBytes, err := jsoniter.Marshal(response.Next)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to serialize next hook data: %w", err) return "", fmt.Errorf("failed to serialize next hook data: %w", err)
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// Priority 2: Check for "completion" field (standard LLM response) // Priority 2: Check Completion field (standard LLM response)
// Extract text content from the LLM completion // Extract text content from the LLM completion
if completion, hasCompletion := responseMap["completion"]; hasCompletion && completion != nil { if response.Completion != nil {
if completionMap, ok := completion.(map[string]interface{}); ok { // Content can be string or []ContentPart (multimodal)
// Extract content from completion switch v := response.Completion.Content.(type) {
if content, hasContent := completionMap["content"]; hasContent { case string:
// Content can be string or []ContentPart (multimodal) // Simple text content
switch v := content.(type) { return v, nil
case string: case []interface{}:
// Simple text content // Multimodal content array - extract all text parts
return v, nil var text string
case []interface{}: for _, part := range v {
// Multimodal content array - extract all text parts if partMap, ok := part.(map[string]interface{}); ok {
var text string if partType, _ := partMap["type"].(string); partType == "text" {
for _, part := range v { if textContent, ok := partMap["text"].(string); ok {
if partMap, ok := part.(map[string]interface{}); ok { text += textContent
if partType, _ := partMap["type"].(string); partType == "text" {
if textContent, ok := partMap["text"].(string); ok {
text += textContent
}
}
} }
} }
if text != "" {
return text, nil
}
// No text found in content parts
return "", fmt.Errorf("no text content found in completion content parts")
} }
} }
if text != "" {
return text, nil
}
// No text found in content parts
return "", fmt.Errorf("no text content found in completion content parts")
} }
} }
// Fallback: Try to find a "content" field directly (shouldn't happen normally) // No content found
if content, hasContent := responseMap["content"]; hasContent { return "", fmt.Errorf("no content found in agent response")
if contentStr, ok := content.(string); ok {
return contentStr, nil
}
}
// Last resort: JSON stringify the entire response
jsonBytes, err := jsoniter.Marshal(response)
if err != nil {
return "", fmt.Errorf("failed to serialize agent response: %w", err)
}
return string(jsonBytes), nil
} }
// CallMCPTool calls an MCP tool to process content // CallMCPTool calls an MCP tool to process content

View file

@ -150,46 +150,20 @@ func (p *AgentProvider) Search(ctx *agentContext.Context, req *types.Request) (*
}, nil }, nil
} }
// parseAgentResponse parses the agent response into search result items // parseAgentResponse parses the agent's *context.Response into search result items
// The agent should return a JSON structure with search results // Now that agent.Stream() returns *context.Response directly,
func (p *AgentProvider) parseAgentResponse(response interface{}, source types.SourceType) ([]*types.ResultItem, int, string) { // we can access fields without type assertions.
if response == nil { //
// The agent returns search results in response.Next field
func (p *AgentProvider) parseAgentResponse(response *agentContext.Response, source types.SourceType) ([]*types.ResultItem, int, string) {
if response == nil || response.Next == nil {
return nil, 0, "Agent returned nil response" return nil, 0, "Agent returned nil response"
} }
// Try to extract data from response // Extract data from Next field
var data map[string]interface{} data := extractNextData(response.Next)
if data == nil {
// Handle different response types return nil, 0, "Failed to extract data from agent response"
switch v := response.(type) {
case map[string]interface{}:
data = v
case string:
// Try to parse as JSON
if err := json.Unmarshal([]byte(v), &data); err != nil {
return nil, 0, fmt.Sprintf("Failed to parse agent response as JSON: %v", err)
}
default:
// Try to marshal and unmarshal
jsonBytes, err := json.Marshal(response)
if err != nil {
return nil, 0, fmt.Sprintf("Failed to serialize agent response: %v", err)
}
if err := json.Unmarshal(jsonBytes, &data); err != nil {
return nil, 0, fmt.Sprintf("Failed to parse agent response: %v", err)
}
}
// Check for "next" field (custom hook data)
if next, hasNext := data["next"]; hasNext && next != nil {
if nextMap, ok := next.(map[string]interface{}); ok {
data = nextMap
} else if nextStr, ok := next.(string); ok {
// Try to parse as JSON
if err := json.Unmarshal([]byte(nextStr), &data); err != nil {
return nil, 0, fmt.Sprintf("Failed to parse next hook data: %v", err)
}
}
} }
// Extract items from data // Extract items from data
@ -230,3 +204,34 @@ func (p *AgentProvider) parseAgentResponse(response interface{}, source types.So
return items, total, "" return items, total, ""
} }
// extractNextData extracts the actual data from response.Next field
// Handles nested structures like { "data": { ... } }
func extractNextData(next interface{}) map[string]interface{} {
if next == nil {
return nil
}
switch v := next.(type) {
case map[string]interface{}:
// Check for "data" wrapper
if data, ok := v["data"].(map[string]interface{}); ok {
return data
}
return v
case string:
// Try to parse as JSON
var data map[string]interface{}
if err := json.Unmarshal([]byte(v), &data); err == nil {
return extractNextData(data)
}
}
// Try to handle other types by converting to JSON and back
if bytes, err := json.Marshal(next); err == nil {
var data map[string]interface{}
if err := json.Unmarshal(bytes, &data); err == nil {
return extractNextData(data)
}
}
return nil
}

View file

@ -64,30 +64,38 @@ func (p *AgentProvider) Extract(ctx *agentContext.Context, content string, opts
}, },
} }
result, err := agent.Stream(ctx, messages, options) response, err := agent.Stream(ctx, messages, options)
if err != nil { if err != nil {
return nil, fmt.Errorf("agent call failed: %w", err) return nil, fmt.Errorf("agent call failed: %w", err)
} }
// Debug: log the result type and value // Parse the result from response.Next
// fmt.Printf("DEBUG Agent result type: %T, value: %+v\n", result, result) return p.parseResponse(response)
// Parse the result
return p.parseResult(result)
} }
// parseResult extracts keywords from the agent's response // parseResponse extracts keywords from the agent's *context.Response
// The agent should return data in NextHookResponse format: { data: { keywords: [...] } } // Now that agent.Stream() returns *context.Response directly,
// The Stream() response wraps this in: { next: { data: { keywords: [...] } } } // we can access fields without type assertions.
func (p *AgentProvider) parseResult(result interface{}) ([]string, error) { //
if result == nil { // The agent returns keywords in response.Next field
func (p *AgentProvider) parseResponse(response *agentContext.Response) ([]string, error) {
if response == nil || response.Next == nil {
return []string{}, nil
}
return p.parseNextData(response.Next)
}
// parseNextData extracts keywords from Next hook data
func (p *AgentProvider) parseNextData(next interface{}) ([]string, error) {
if next == nil {
return []string{}, nil return []string{}, nil
} }
// Try to convert to map first (most common case) // Try to convert to map first (most common case)
var data map[string]interface{} var data map[string]interface{}
switch v := result.(type) { switch v := next.(type) {
case map[string]interface{}: case map[string]interface{}:
data = v data = v
case string: case string:
@ -113,7 +121,7 @@ func (p *AgentProvider) parseResult(result interface{}) ([]string, error) {
return keywords, nil return keywords, nil
default: default:
// Try to marshal and unmarshal // Try to marshal and unmarshal
jsonBytes, err := json.Marshal(result) jsonBytes, err := json.Marshal(next)
if err != nil { if err != nil {
return []string{}, nil return []string{}, nil
} }
@ -122,18 +130,6 @@ func (p *AgentProvider) parseResult(result interface{}) ([]string, error) {
} }
} }
// Check for "next" field (custom hook data from NextHookResponse)
// Stream() returns: { next: { data: { keywords: [...] } } }
if next, hasNext := data["next"]; hasNext && next != nil {
if nextMap, ok := next.(map[string]interface{}); ok {
data = nextMap
} else if nextStr, ok := next.(string); ok {
if err := json.Unmarshal([]byte(nextStr), &data); err != nil {
return []string{}, nil
}
}
}
// Extract keywords from data // Extract keywords from data
// Try common field names: "keywords", "data", "data.keywords" // Try common field names: "keywords", "data", "data.keywords"
if kw, ok := data["keywords"]; ok { if kw, ok := data["keywords"]; ok {

View file

@ -70,8 +70,8 @@ func (p *AgentProvider) Generate(ctx *agentContext.Context, input *Input) (*Resu
continue continue
} }
// Parse the result // Parse the result from response
genResult, err := p.parseResult(result) genResult, err := p.parseResponse(result)
if err != nil { if err != nil {
lastError = err lastError = err
continue continue
@ -155,28 +155,36 @@ func (p *AgentProvider) validateDSL(dsl *gou.QueryDSL) *linter.LintResult {
return lintResult return lintResult
} }
// parseResult extracts QueryDSL from the agent's response // parseResponse extracts QueryDSL from the agent's *context.Response
// The querydsl agent returns QueryDSL JSON directly (not wrapped in {dsl: ...}) // Now that agent.Stream() returns *context.Response directly,
// we can access fields without type assertions.
//
// The querydsl agent returns QueryDSL in response.Next field
// Or returns error JSON: {"error": "code", "message": "..."} // Or returns error JSON: {"error": "code", "message": "..."}
// Stream() returns *context.Response with QueryDSL in "next" field func (p *AgentProvider) parseResponse(response *agentContext.Response) (*Result, error) {
func (p *AgentProvider) parseResult(result interface{}) (*Result, error) { if response == nil {
if result == nil {
return &Result{}, nil return &Result{}, nil
} }
// Handle *context.Response directly (most common case from Stream()) // Check Next field first (custom hook data)
if resp, ok := result.(*agentContext.Response); ok { if response.Next != nil {
if resp.Next != nil { return p.parseNextData(response.Next)
// Next contains the hook response, recursively parse it }
return p.parseResult(resp.Next)
} // No Next data, return empty result
return &Result{}, nil
}
// parseNextData extracts QueryDSL from Next hook data
func (p *AgentProvider) parseNextData(next interface{}) (*Result, error) {
if next == nil {
return &Result{}, nil return &Result{}, nil
} }
// Try to convert to map first // Try to convert to map first
var data map[string]interface{} var data map[string]interface{}
switch v := result.(type) { switch v := next.(type) {
case map[string]interface{}: case map[string]interface{}:
data = v data = v
case string: case string:
@ -186,7 +194,7 @@ func (p *AgentProvider) parseResult(result interface{}) (*Result, error) {
} }
default: default:
// Try to marshal and unmarshal // Try to marshal and unmarshal
jsonBytes, err := json.Marshal(result) jsonBytes, err := json.Marshal(next)
if err != nil { if err != nil {
return &Result{}, nil return &Result{}, nil
} }
@ -197,18 +205,6 @@ func (p *AgentProvider) parseResult(result interface{}) (*Result, error) {
genResult := &Result{} genResult := &Result{}
// Check for Stream() wrapper: { content: "...", next: {...} }
// The actual response is in "content" field as a string
if content, hasContent := data["content"]; hasContent && content != nil {
if contentStr, ok := content.(string); ok && contentStr != "" {
// Parse the content string as JSON
var contentData map[string]interface{}
if err := json.Unmarshal([]byte(contentStr), &contentData); err == nil {
data = contentData
}
}
}
// Check for error response: {"error": "code", "message": "..."} // Check for error response: {"error": "code", "message": "..."}
if errCode, hasError := data["error"]; hasError { if errCode, hasError := data["error"]; hasError {
errMsg := "" errMsg := ""
@ -229,22 +225,6 @@ func (p *AgentProvider) parseResult(result interface{}) (*Result, error) {
return genResult, nil return genResult, nil
} }
// Fallback: check for wrapped formats
// Check for "next" field (custom hook data from NextHookResponse)
if next, hasNext := data["next"]; hasNext && next != nil {
if nextMap, ok := next.(map[string]interface{}); ok {
data = nextMap
} else if nextStr, ok := next.(string); ok {
if err := json.Unmarshal([]byte(nextStr), &data); err == nil {
// Check if parsed data is a QueryDSL
if _, hasFrom := data["from"]; hasFrom {
genResult.DSL = p.extractDSL(data)
return genResult, nil
}
}
}
}
// Check for "dsl" field wrapper: { dsl: {...} } // Check for "dsl" field wrapper: { dsl: {...} }
if dsl, ok := data["dsl"]; ok { if dsl, ok := data["dsl"]; ok {
genResult.DSL = p.extractDSL(dsl) genResult.DSL = p.extractDSL(dsl)

View file

@ -59,20 +59,24 @@ func (p *AgentProvider) Rerank(ctx *context.Context, query string, items []*type
}, },
} }
result, err := agent.Stream(ctx, messages, options) response, err := agent.Stream(ctx, messages, options)
if err != nil { if err != nil {
return nil, fmt.Errorf("agent stream failed: %w", err) return nil, fmt.Errorf("agent stream failed: %w", err)
} }
// Parse response // Parse response from response.Next
return p.parseResponse(result, items, opts) return p.parseAgentResponse(response, items, opts)
} }
// parseResponse extracts reranked items from agent response // parseAgentResponse extracts reranked items from agent's *context.Response
// The response format from agent.Stream is typically: // Now that agent.Stream() returns *context.Response directly,
// { "next": { "data": { "order": [...] } } } // we can access fields without type assertions.
func (p *AgentProvider) parseResponse(result interface{}, originalItems []*types.ResultItem, opts *types.RerankOptions) ([]*types.ResultItem, error) { //
if result == nil { // Expected response.Next format:
// { "order": ["ref_001", "ref_003", "ref_002"] }
// Or: { "items": [{ "citation_id": "ref_001", ... }, ...] }
func (p *AgentProvider) parseAgentResponse(response *context.Response, originalItems []*types.ResultItem, opts *types.RerankOptions) ([]*types.ResultItem, error) {
if response == nil || response.Next == nil {
return originalItems, nil return originalItems, nil
} }
@ -84,20 +88,20 @@ func (p *AgentProvider) parseResponse(result interface{}, originalItems []*types
} }
} }
// Extract response data // Extract response data from Next field
response := extractResponseData(result) data := extractNextData(response.Next)
if response == nil { if data == nil {
return originalItems, nil return originalItems, nil
} }
// Try to get reranked order from response // Try to get reranked order from data
// Expected format: { "order": ["ref_001", "ref_003", "ref_002"] } // Expected format: { "order": ["ref_001", "ref_003", "ref_002"] }
// Or: { "items": [{ "citation_id": "ref_001", ... }, ...] } // Or: { "items": [{ "citation_id": "ref_001", ... }, ...] }
var reranked []*types.ResultItem var reranked []*types.ResultItem
// Try "order" field (list of citation IDs) // Try "order" field (list of citation IDs)
if order, ok := response["order"]; ok { if order, ok := data["order"]; ok {
if orderList := toStringSlice(order); len(orderList) > 0 { if orderList := toStringSlice(order); len(orderList) > 0 {
for _, id := range orderList { for _, id := range orderList {
if item, exists := itemMap[id]; exists { if item, exists := itemMap[id]; exists {
@ -116,7 +120,7 @@ func (p *AgentProvider) parseResponse(result interface{}, originalItems []*types
// Try "items" field (full items or items with citation_id) // Try "items" field (full items or items with citation_id)
if len(reranked) == 0 { if len(reranked) == 0 {
if items, ok := response["items"]; ok { if items, ok := data["items"]; ok {
if itemsList := toItemsList(items); len(itemsList) > 0 { if itemsList := toItemsList(items); len(itemsList) > 0 {
for _, respItem := range itemsList { for _, respItem := range itemsList {
// Check if it's just a reference or full item // Check if it's just a reference or full item
@ -150,20 +154,16 @@ func (p *AgentProvider) parseResponse(result interface{}, originalItems []*types
return reranked, nil return reranked, nil
} }
// extractResponseData extracts the actual response data from agent.Stream result // extractNextData extracts the actual data from response.Next field
// Handles nested structures like { "next": { "data": { ... } } } // Handles nested structures like { "data": { ... } }
func extractResponseData(result interface{}) map[string]interface{} { func extractNextData(next interface{}) map[string]interface{} {
switch v := result.(type) { if next == nil {
return nil
}
switch v := next.(type) {
case map[string]interface{}: case map[string]interface{}:
// Check for "next" wrapper (from NextHookResponse) // Check for "data" wrapper
if next, ok := v["next"].(map[string]interface{}); ok {
// Check for "data" inside next
if data, ok := next["data"].(map[string]interface{}); ok {
return data
}
return next
}
// Check for direct "data" wrapper
if data, ok := v["data"].(map[string]interface{}); ok { if data, ok := v["data"].(map[string]interface{}); ok {
return data return data
} }
@ -172,16 +172,14 @@ func extractResponseData(result interface{}) map[string]interface{} {
// Try to parse as JSON // Try to parse as JSON
var data map[string]interface{} var data map[string]interface{}
if err := json.Unmarshal([]byte(v), &data); err == nil { if err := json.Unmarshal([]byte(v), &data); err == nil {
return extractResponseData(data) return extractNextData(data)
} }
} }
// Try to handle other types by converting to JSON and back // Try to handle other types by converting to JSON and back
if result != nil { if bytes, err := json.Marshal(next); err == nil {
if bytes, err := json.Marshal(result); err == nil { var data map[string]interface{}
var data map[string]interface{} if err := json.Unmarshal(bytes, &data); err == nil {
if err := json.Unmarshal(bytes, &data); err == nil { return extractNextData(data)
return extractResponseData(data)
}
} }
} }
return nil return nil

View file

@ -532,26 +532,23 @@ func buildContextOptions(tc *Case, runnerOpts *Options) *context.Options {
} }
// extractOutput extracts the output from the agent response // extractOutput extracts the output from the agent response
// Priority: Next hook data (if non-empty) > Completion content > raw response // Priority: Next hook data (if non-empty) > Completion content > nil
func extractOutput(response interface{}) interface{} { func extractOutput(response *context.Response) interface{} {
if response == nil { if response == nil {
return nil return nil
} }
// Try to get data from context.Response // Prefer Next hook data if available and non-empty
if resp, ok := response.(*context.Response); ok { // response.Next is already the Data value (not NextHookResponse struct)
// Prefer Next hook data if available and non-empty if response.Next != nil && !isEmptyValue(response.Next) {
// resp.Next is already the Data value (not NextHookResponse struct) return response.Next
if resp.Next != nil && !isEmptyValue(resp.Next) { }
return resp.Next // Fall back to raw completion content
} if response.Completion != nil {
// Fall back to raw completion content return response.Completion.Content
if resp.Completion != nil {
return resp.Completion.Content
}
} }
return response return nil
} }
// isEmptyValue checks if a value is considered "empty" for output purposes // isEmptyValue checks if a value is considered "empty" for output purposes