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
// 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
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
// ================================================
var finalResponse interface{}
var finalResponse *context.Response
var nextResponse *context.NextHookResponse = nil
if ast.HookScript != nil {

View file

@ -56,19 +56,18 @@ func TestAgentNextStandard(t *testing.T) {
assert.NoError(t, err)
assert.NotNil(t, response)
resp := response.(*context.Response)
assert.NotNil(t, resp.Completion)
assert.Nil(t, resp.Next)
assert.NotNil(t, response.Completion)
assert.Nil(t, response.Next)
// Verify response structure
assert.Equal(t, "tests.realworld-next", resp.AssistantID)
assert.NotEmpty(t, resp.ContextID)
assert.NotEmpty(t, resp.RequestID)
assert.NotEmpty(t, resp.TraceID)
assert.NotEmpty(t, resp.ChatID)
assert.Equal(t, "tests.realworld-next", response.AssistantID)
assert.NotEmpty(t, response.ContextID)
assert.NotEmpty(t, response.RequestID)
assert.NotEmpty(t, response.TraceID)
assert.NotEmpty(t, response.ChatID)
// Verify completion has content
assert.NotNil(t, resp.Completion.Content)
assert.NotNil(t, response.Completion.Content)
t.Log("✓ Standard response test passed")
}
@ -94,19 +93,18 @@ func TestAgentNextCustomData(t *testing.T) {
assert.NoError(t, err)
assert.NotNil(t, response)
resp := response.(*context.Response)
assert.NotNil(t, resp.Completion)
assert.NotNil(t, resp.Next)
assert.NotNil(t, response.Completion)
assert.NotNil(t, response.Next)
// Verify response structure
assert.Equal(t, "tests.realworld-next", resp.AssistantID)
assert.NotEmpty(t, resp.ContextID)
assert.NotEmpty(t, resp.RequestID)
assert.NotEmpty(t, resp.TraceID)
assert.Equal(t, "tests.realworld-next", response.AssistantID)
assert.NotEmpty(t, response.ContextID)
assert.NotEmpty(t, response.RequestID)
assert.NotEmpty(t, response.TraceID)
// Verify custom data structure (from scenarioCustomData)
// resp.Next contains the "data" field value from NextHookResponse
nextData, ok := resp.Next.(map[string]interface{})
// response.Next contains the "data" field value from NextHookResponse
nextData, ok := response.Next.(map[string]interface{})
assert.True(t, ok, "Next should be a map")
assert.Equal(t, "custom_response", nextData["type"])
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.NotNil(t, response)
resp := response.(*context.Response)
// Verify response structure
assert.NotEmpty(t, resp.AssistantID)
assert.NotEmpty(t, resp.ContextID)
assert.NotEmpty(t, resp.RequestID)
assert.NotEmpty(t, resp.TraceID)
assert.NotEmpty(t, response.AssistantID)
assert.NotEmpty(t, response.ContextID)
assert.NotEmpty(t, response.RequestID)
assert.NotEmpty(t, response.TraceID)
// Verify completion (delegated agent should have returned completion)
assert.NotNil(t, resp.Completion)
assert.NotNil(t, resp.Completion.Content)
assert.NotNil(t, response.Completion)
assert.NotNil(t, response.Completion.Content)
// Next should be from the delegated agent
// 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
@ -177,18 +173,17 @@ func TestAgentNextConditional(t *testing.T) {
assert.NoError(t, err)
assert.NotNil(t, response)
resp := response.(*context.Response)
assert.NotNil(t, resp.Next)
assert.NotNil(t, response.Next)
// Verify response structure
assert.Equal(t, "tests.realworld-next", resp.AssistantID)
assert.NotEmpty(t, resp.ContextID)
assert.NotEmpty(t, resp.RequestID)
assert.NotEmpty(t, resp.TraceID)
assert.Equal(t, "tests.realworld-next", response.AssistantID)
assert.NotEmpty(t, response.ContextID)
assert.NotEmpty(t, response.RequestID)
assert.NotEmpty(t, response.TraceID)
// Verify conditional response structure (from scenarioConditional)
// resp.Next contains the "data" field value from NextHookResponse
nextData, ok := resp.Next.(map[string]interface{})
// response.Next contains the "data" field value from NextHookResponse
nextData, ok := response.Next.(map[string]interface{})
assert.True(t, ok, "Next should be a map")
assert.Equal(t, "Conditional analysis complete", nextData["message"])
assert.Contains(t, nextData, "action")
@ -224,19 +219,18 @@ func TestAgentWithoutNextHook(t *testing.T) {
assert.NoError(t, err)
assert.NotNil(t, response)
resp := response.(*context.Response)
assert.Nil(t, resp.Next)
assert.Nil(t, response.Next)
// Verify response structure
assert.Equal(t, "tests.create", resp.AssistantID)
assert.NotEmpty(t, resp.ContextID)
assert.NotEmpty(t, resp.RequestID)
assert.NotEmpty(t, resp.TraceID)
assert.NotEmpty(t, resp.ChatID)
assert.Equal(t, "tests.create", response.AssistantID)
assert.NotEmpty(t, response.ContextID)
assert.NotEmpty(t, response.RequestID)
assert.NotEmpty(t, response.TraceID)
assert.NotEmpty(t, response.ChatID)
// Verify completion
assert.NotNil(t, resp.Completion)
assert.NotNil(t, resp.Completion.Content)
assert.NotNil(t, response.Completion)
assert.NotNil(t, response.Completion.Content)
t.Log("✓ No Next Hook test passed")
}

View file

@ -51,7 +51,7 @@ type agentCallerWrapper struct {
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...)
}

View file

@ -8,7 +8,7 @@ import (
)
// 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 npc.NextResponse == nil {
return ast.buildStandardResponse(npc), nil
@ -42,7 +42,7 @@ func (ast *Assistant) handleDelegation(
ctx *agentContext.Context,
delegate *agentContext.DelegateConfig,
streamHandler func(message.StreamChunkType, []byte) int,
) (interface{}, error) {
) (*agentContext.Response, error) {
// Load the target assistant
targetAssistant, err := Get(delegate.AgentID)
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
func (ast *Assistant) buildStandardResponse(npc *NextProcessContext) interface{} {
func (ast *Assistant) buildStandardResponse(npc *NextProcessContext) *agentContext.Response {
return &agentContext.Response{
ContextID: npc.Context.ID,
RequestID: npc.Context.RequestID(),

View file

@ -98,39 +98,37 @@ func (ast *Assistant) checkSearchIntent(ctx *context.Context, messages []context
// Parse the result
// 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
if response.Next != nil {
if nextData, ok := response.Next.(map[string]interface{}); ok {
// Check for data field (from Next hook's {data: result})
var intentData map[string]interface{}
if data, ok := nextData["data"].(map[string]interface{}); ok {
intentData = data
} else {
intentData = nextData
}
// First try to get from Next hook response
if result.Next != nil {
if nextData, ok := result.Next.(map[string]interface{}); ok {
// Check for data field (from Next hook's {data: result})
var intentData map[string]interface{}
if data, ok := nextData["data"].(map[string]interface{}); ok {
intentData = data
} else {
intentData = nextData
}
if needSearch, ok := intentData["need_search"].(bool); ok {
reason, _ := intentData["reason"].(string)
ctx.Logger.Debug("Search intent (from Next): need_search=%v, reason=%s", needSearch, reason)
ast.sendIntentDone(ctx, loadingID, needSearch, reason)
return needSearch
}
if needSearch, ok := intentData["need_search"].(bool); ok {
reason, _ := intentData["reason"].(string)
ctx.Logger.Debug("Search intent (from Next): need_search=%v, reason=%s", needSearch, reason)
ast.sendIntentDone(ctx, loadingID, needSearch, reason)
return needSearch
}
}
}
// Fallback: parse from Completion.Content if Next hook didn't process
if response.Completion != nil {
content, ok := response.Completion.Content.(string)
if !ok || content == "" {
ast.sendIntentDone(ctx, loadingID, 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
// Fallback: parse from Completion.Content if Next hook didn't process
if result.Completion != nil {
content, ok := result.Completion.Content.(string)
if !ok || content == "" {
ast.sendIntentDone(ctx, loadingID, 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
}
// 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.NotNil(t, response)
resp := response.(*context.Response)
assert.NotNil(t, resp.Completion, "should have completion")
assert.NotNil(t, response.Completion, "should have completion")
t.Logf("✓ Stream executed without search (disabled)")
})
}

View file

@ -118,8 +118,7 @@ func TestSearchAutoFull(t *testing.T) {
}
require.NotNil(t, response)
resp := response.(*context.Response)
assert.NotNil(t, resp.Completion, "should have completion")
assert.NotNil(t, response.Completion, "should have completion")
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.NotNil(t, response)
resp := response.(*context.Response)
assert.NotNil(t, resp.Completion, "should have completion")
assert.NotNil(t, response.Completion, "should have completion")
t.Logf("✓ Stream executed with hook disabling search")
})
}

View file

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

View file

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

View file

@ -9,7 +9,7 @@ import (
// AgentCaller interface for calling agents (to avoid circular dependency)
// Used by content handlers (vision, audio, etc.) and search handlers (agent mode)
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

View file

@ -42,10 +42,10 @@ func CallAgent(ctx *agentContext.Context, agentID string, message agentContext.M
// Extract text from agent response
// Two formats are supported:
// 1. Custom Hook response (from Next hook)
// 2. Standard Agent Stream response (LLM completion)
// 1. Custom Hook response (from Next hook) - response.Next
// 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
@ -120,116 +120,63 @@ func CallAgentWithFileInfo(ctx *agentContext.Context, agentID string, message ag
return CallAgent(ctx, agentID, message)
}
// extractTextFromAgentResponse extracts text from agent response
// Handles two main response formats from agent.Stream():
//
// 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
// extractTextFromResponse extracts text from agent response
// Now that agent.Stream() returns *agentContext.Response directly,
// we can access fields without type assertions or JSON conversion.
//
// Priority:
// 1. Check for "next" field (custom hook data) → return complete data
// 2. Check for "completion" field (standard LLM response) → extract text only
// 3. Fallback to direct string or JSON stringify
func extractTextFromAgentResponse(response interface{}) (string, error) {
// 1. Check response.Next (custom hook data) → return complete data
// 2. Check response.Completion (standard LLM response) → extract text only
func extractTextFromResponse(response *agentContext.Response) (string, error) {
if response == nil {
return "", fmt.Errorf("agent returned nil response")
}
// First, try to convert to map if it's a struct
// agent.Stream() may return *agentContext.Response which needs to be converted
var responseMap map[string]interface{}
// 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 {
// Priority 1: Check Next field (custom hook data)
// If Next hook returns custom data, return the complete structure
if response.Next != nil {
// If next is a string, return directly
if nextStr, ok := next.(string); ok {
if nextStr, ok := response.Next.(string); ok {
return nextStr, nil
}
// Otherwise, JSON stringify to preserve complete structure
jsonBytes, err := jsoniter.Marshal(next)
jsonBytes, err := jsoniter.Marshal(response.Next)
if err != nil {
return "", fmt.Errorf("failed to serialize next hook data: %w", err)
}
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
if completion, hasCompletion := responseMap["completion"]; hasCompletion && completion != nil {
if completionMap, ok := completion.(map[string]interface{}); ok {
// Extract content from completion
if content, hasContent := completionMap["content"]; hasContent {
// Content can be string or []ContentPart (multimodal)
switch v := content.(type) {
case string:
// Simple text content
return v, nil
case []interface{}:
// Multimodal content array - extract all text parts
var text string
for _, part := range v {
if partMap, ok := part.(map[string]interface{}); ok {
if partType, _ := partMap["type"].(string); partType == "text" {
if textContent, ok := partMap["text"].(string); ok {
text += textContent
}
}
if response.Completion != nil {
// Content can be string or []ContentPart (multimodal)
switch v := response.Completion.Content.(type) {
case string:
// Simple text content
return v, nil
case []interface{}:
// Multimodal content array - extract all text parts
var text string
for _, part := range v {
if partMap, ok := part.(map[string]interface{}); ok {
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)
if content, hasContent := responseMap["content"]; hasContent {
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
// No content found
return "", fmt.Errorf("no content found in agent response")
}
// 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
}
// parseAgentResponse parses the agent response into search result items
// The agent should return a JSON structure with search results
func (p *AgentProvider) parseAgentResponse(response interface{}, source types.SourceType) ([]*types.ResultItem, int, string) {
if response == nil {
// parseAgentResponse parses the agent's *context.Response into search result items
// Now that agent.Stream() returns *context.Response directly,
// we can access fields without type assertions.
//
// 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"
}
// Try to extract data from response
var data map[string]interface{}
// Handle different response types
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 data from Next field
data := extractNextData(response.Next)
if data == nil {
return nil, 0, "Failed to extract data from agent response"
}
// Extract items from data
@ -230,3 +204,34 @@ func (p *AgentProvider) parseAgentResponse(response interface{}, source types.So
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 {
return nil, fmt.Errorf("agent call failed: %w", err)
}
// Debug: log the result type and value
// fmt.Printf("DEBUG Agent result type: %T, value: %+v\n", result, result)
// Parse the result
return p.parseResult(result)
// Parse the result from response.Next
return p.parseResponse(response)
}
// parseResult extracts keywords from the agent's response
// The agent should return data in NextHookResponse format: { data: { keywords: [...] } }
// The Stream() response wraps this in: { next: { data: { keywords: [...] } } }
func (p *AgentProvider) parseResult(result interface{}) ([]string, error) {
if result == nil {
// parseResponse extracts keywords from the agent's *context.Response
// Now that agent.Stream() returns *context.Response directly,
// we can access fields without type assertions.
//
// 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
}
// Try to convert to map first (most common case)
var data map[string]interface{}
switch v := result.(type) {
switch v := next.(type) {
case map[string]interface{}:
data = v
case string:
@ -113,7 +121,7 @@ func (p *AgentProvider) parseResult(result interface{}) ([]string, error) {
return keywords, nil
default:
// Try to marshal and unmarshal
jsonBytes, err := json.Marshal(result)
jsonBytes, err := json.Marshal(next)
if err != 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
// Try common field names: "keywords", "data", "data.keywords"
if kw, ok := data["keywords"]; ok {

View file

@ -70,8 +70,8 @@ func (p *AgentProvider) Generate(ctx *agentContext.Context, input *Input) (*Resu
continue
}
// Parse the result
genResult, err := p.parseResult(result)
// Parse the result from response
genResult, err := p.parseResponse(result)
if err != nil {
lastError = err
continue
@ -155,28 +155,36 @@ func (p *AgentProvider) validateDSL(dsl *gou.QueryDSL) *linter.LintResult {
return lintResult
}
// parseResult extracts QueryDSL from the agent's response
// The querydsl agent returns QueryDSL JSON directly (not wrapped in {dsl: ...})
// parseResponse extracts QueryDSL from the agent's *context.Response
// 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": "..."}
// Stream() returns *context.Response with QueryDSL in "next" field
func (p *AgentProvider) parseResult(result interface{}) (*Result, error) {
if result == nil {
func (p *AgentProvider) parseResponse(response *agentContext.Response) (*Result, error) {
if response == nil {
return &Result{}, nil
}
// Handle *context.Response directly (most common case from Stream())
if resp, ok := result.(*agentContext.Response); ok {
if resp.Next != nil {
// Next contains the hook response, recursively parse it
return p.parseResult(resp.Next)
}
// Check Next field first (custom hook data)
if response.Next != nil {
return p.parseNextData(response.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
}
// Try to convert to map first
var data map[string]interface{}
switch v := result.(type) {
switch v := next.(type) {
case map[string]interface{}:
data = v
case string:
@ -186,7 +194,7 @@ func (p *AgentProvider) parseResult(result interface{}) (*Result, error) {
}
default:
// Try to marshal and unmarshal
jsonBytes, err := json.Marshal(result)
jsonBytes, err := json.Marshal(next)
if err != nil {
return &Result{}, nil
}
@ -197,18 +205,6 @@ func (p *AgentProvider) parseResult(result interface{}) (*Result, error) {
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": "..."}
if errCode, hasError := data["error"]; hasError {
errMsg := ""
@ -229,22 +225,6 @@ func (p *AgentProvider) parseResult(result interface{}) (*Result, error) {
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: {...} }
if dsl, ok := data["dsl"]; ok {
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 {
return nil, fmt.Errorf("agent stream failed: %w", err)
}
// Parse response
return p.parseResponse(result, items, opts)
// Parse response from response.Next
return p.parseAgentResponse(response, items, opts)
}
// parseResponse extracts reranked items from agent response
// The response format from agent.Stream is typically:
// { "next": { "data": { "order": [...] } } }
func (p *AgentProvider) parseResponse(result interface{}, originalItems []*types.ResultItem, opts *types.RerankOptions) ([]*types.ResultItem, error) {
if result == nil {
// parseAgentResponse extracts reranked items from agent's *context.Response
// Now that agent.Stream() returns *context.Response directly,
// we can access fields without type assertions.
//
// 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
}
@ -84,20 +88,20 @@ func (p *AgentProvider) parseResponse(result interface{}, originalItems []*types
}
}
// Extract response data
response := extractResponseData(result)
if response == nil {
// Extract response data from Next field
data := extractNextData(response.Next)
if data == 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"] }
// Or: { "items": [{ "citation_id": "ref_001", ... }, ...] }
var reranked []*types.ResultItem
// 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 {
for _, id := range orderList {
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)
if len(reranked) == 0 {
if items, ok := response["items"]; ok {
if items, ok := data["items"]; ok {
if itemsList := toItemsList(items); len(itemsList) > 0 {
for _, respItem := range itemsList {
// 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
}
// extractResponseData extracts the actual response data from agent.Stream result
// Handles nested structures like { "next": { "data": { ... } } }
func extractResponseData(result interface{}) map[string]interface{} {
switch v := result.(type) {
// 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 "next" wrapper (from NextHookResponse)
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
// Check for "data" wrapper
if data, ok := v["data"].(map[string]interface{}); ok {
return data
}
@ -172,16 +172,14 @@ func extractResponseData(result interface{}) map[string]interface{} {
// Try to parse as JSON
var data map[string]interface{}
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
if result != nil {
if bytes, err := json.Marshal(result); err == nil {
var data map[string]interface{}
if err := json.Unmarshal(bytes, &data); err == nil {
return extractResponseData(data)
}
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

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