@@ -124,6 +124,7 @@ func (a *Agent) Clone(opts ...Option) *Agent {
|
||||
copy(newApproval.ToolNames, a.approval.ToolNames)
|
||||
newApproval.toolNameSet = buildToolNameSet(newApproval.ToolNames)
|
||||
}
|
||||
|
||||
cp.approval = &newApproval
|
||||
}
|
||||
|
||||
@@ -205,6 +206,7 @@ func WithMaxTurns(n int) Option {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
|
||||
a.maxTurns = n
|
||||
}
|
||||
}
|
||||
@@ -217,6 +219,7 @@ func WithMaxEmptyOutputRetries(n int) Option {
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
|
||||
a.maxEmptyOutputRetries = n
|
||||
}
|
||||
}
|
||||
@@ -226,6 +229,7 @@ func WithMaxToolDepth(n int) Option {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
|
||||
a.maxToolDepth = n
|
||||
}
|
||||
}
|
||||
@@ -348,6 +352,7 @@ func WithMCPServers(servers ...*MCPServer) Option {
|
||||
|
||||
func WithApproval(config ApprovalConfig) Option {
|
||||
config.toolNameSet = buildToolNameSet(config.ToolNames)
|
||||
|
||||
return func(a *Agent) {
|
||||
a.approval = &config
|
||||
}
|
||||
@@ -369,6 +374,7 @@ func (a *Agent) resolveTools(ctx context.Context) ([]ToolDescriptor, map[string]
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot resolve MCP tools from %q: %w", s.name, err)
|
||||
}
|
||||
|
||||
for _, t := range mcpTools {
|
||||
all = append(all, t)
|
||||
}
|
||||
@@ -380,6 +386,7 @@ func (a *Agent) resolveTools(ctx context.Context) ([]ToolDescriptor, map[string]
|
||||
if _, exists := toolMap[name]; exists {
|
||||
return nil, nil, fmt.Errorf("cannot resolve tools: duplicate tool name %q", name)
|
||||
}
|
||||
|
||||
toolMap[name] = t
|
||||
}
|
||||
|
||||
|
||||
@@ -35,8 +35,10 @@ func (m *mockProvider) ChatCompletion(_ context.Context, _ *llm.ChatCompletionRe
|
||||
if m.calls >= len(m.responses) {
|
||||
return nil, errors.New("no more mock responses")
|
||||
}
|
||||
|
||||
resp := m.responses[m.calls]
|
||||
m.calls++
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -56,6 +58,7 @@ func (s *mockChatStream) Next() bool {
|
||||
func (s *mockChatStream) Event() llm.ChatCompletionStreamEvent {
|
||||
ev := s.events[s.pos]
|
||||
s.pos++
|
||||
|
||||
return ev
|
||||
}
|
||||
|
||||
@@ -89,8 +92,10 @@ func (p *mockMultiStreamProvider) ChatCompletionStream(_ context.Context, _ *llm
|
||||
if p.calls >= len(p.streams) {
|
||||
return nil, errors.New("no more mock streams")
|
||||
}
|
||||
|
||||
s := p.streams[p.calls]
|
||||
p.calls++
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
@@ -115,6 +120,7 @@ func (g *blockingGuardrail) Check(_ context.Context, messages []llm.Message) (*a
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -129,6 +135,7 @@ func (g *outputBlocker) Check(_ context.Context, message llm.Message) (*agent.Gu
|
||||
Message: "output blocked",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -192,6 +199,7 @@ func (s *testSession) Load(_ context.Context, sessionID string) ([]llm.Message,
|
||||
msgs := s.messages[sessionID]
|
||||
cp := make([]llm.Message, len(msgs))
|
||||
copy(cp, msgs)
|
||||
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
@@ -199,6 +207,7 @@ func (s *testSession) Save(_ context.Context, sessionID string, messages []llm.M
|
||||
cp := make([]llm.Message, len(messages))
|
||||
copy(cp, messages)
|
||||
s.messages[sessionID] = cp
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -410,6 +419,7 @@ func TestRun(t *testing.T) {
|
||||
}
|
||||
|
||||
type Params struct{}
|
||||
|
||||
noopTool := agent.FunctionTool[Params](
|
||||
"noop",
|
||||
"No-op",
|
||||
@@ -432,6 +442,7 @@ func TestRun(t *testing.T) {
|
||||
)
|
||||
|
||||
require.Error(t, err)
|
||||
|
||||
var maxTurnsErr *agent.MaxTurnsExceededError
|
||||
require.ErrorAs(t, err, &maxTurnsErr)
|
||||
assert.Equal(t, 2, maxTurnsErr.MaxTurns)
|
||||
@@ -444,6 +455,7 @@ func TestRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct{}
|
||||
|
||||
makeTool := func(name string) agent.Tool {
|
||||
tool := agent.FunctionTool[Params](
|
||||
name,
|
||||
@@ -452,6 +464,7 @@ func TestRun(t *testing.T) {
|
||||
return agent.ToolResult{Content: "ok"}, nil
|
||||
},
|
||||
)
|
||||
|
||||
return tool
|
||||
}
|
||||
|
||||
@@ -600,11 +613,13 @@ func TestRun(t *testing.T) {
|
||||
assert.Equal(t, "Both done.", result.FinalMessage().Text())
|
||||
|
||||
var toolMsgs []llm.Message
|
||||
|
||||
for _, m := range result.Messages {
|
||||
if m.Role == llm.RoleTool {
|
||||
toolMsgs = append(toolMsgs, m)
|
||||
}
|
||||
}
|
||||
|
||||
require.Len(t, toolMsgs, 2)
|
||||
assert.Equal(t, "tc_1", toolMsgs[0].ToolCallID)
|
||||
assert.Equal(t, "result_1", toolMsgs[0].Text())
|
||||
@@ -667,11 +682,13 @@ func TestRun(t *testing.T) {
|
||||
assert.Equal(t, "Handled both.", result.FinalMessage().Text())
|
||||
|
||||
var toolMsgs []llm.Message
|
||||
|
||||
for _, m := range result.Messages {
|
||||
if m.Role == llm.RoleTool {
|
||||
toolMsgs = append(toolMsgs, m)
|
||||
}
|
||||
}
|
||||
|
||||
require.Len(t, toolMsgs, 2)
|
||||
assert.Equal(t, "tc_ok", toolMsgs[0].ToolCallID)
|
||||
assert.Equal(t, "success_result", toolMsgs[0].Text())
|
||||
@@ -692,12 +709,14 @@ func TestRun(t *testing.T) {
|
||||
var capturedTenantID string
|
||||
|
||||
type Params struct{}
|
||||
|
||||
tool := agent.FunctionTool[Params](
|
||||
"check_tenant",
|
||||
"Check current tenant",
|
||||
func(ctx context.Context, _ Params) (agent.ToolResult, error) {
|
||||
rc := agent.RunContextFrom[*RequestContext](ctx)
|
||||
capturedTenantID = rc.TenantID
|
||||
|
||||
return agent.ToolResult{Content: "tenant: " + rc.TenantID}, nil
|
||||
},
|
||||
)
|
||||
@@ -912,11 +931,13 @@ func TestRun_Handoff(t *testing.T) {
|
||||
specialist,
|
||||
agent.WithHandoffInputFilter(func(data agent.HandoffInputData) []llm.Message {
|
||||
var filtered []llm.Message
|
||||
|
||||
for _, m := range data.NewItems {
|
||||
if m.Role == llm.RoleUser {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}),
|
||||
),
|
||||
@@ -1017,6 +1038,7 @@ func TestRun_Guardrails(t *testing.T) {
|
||||
)
|
||||
|
||||
require.Error(t, err)
|
||||
|
||||
var tripErr *agent.InputGuardrailTrippedError
|
||||
require.ErrorAs(t, err, &tripErr)
|
||||
assert.Equal(t, "blocker", tripErr.Guardrail)
|
||||
@@ -1048,6 +1070,7 @@ func TestRun_Guardrails(t *testing.T) {
|
||||
)
|
||||
|
||||
require.Error(t, err)
|
||||
|
||||
var tripErr *agent.OutputGuardrailTrippedError
|
||||
require.ErrorAs(t, err, &tripErr)
|
||||
assert.Equal(t, "output_blocker", tripErr.Guardrail)
|
||||
@@ -1064,6 +1087,7 @@ func TestRun_Hooks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct{}
|
||||
|
||||
noopTool := agent.FunctionTool[Params](
|
||||
"noop",
|
||||
"No-op",
|
||||
@@ -1348,6 +1372,7 @@ func TestRun_ToolUseBehavior(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct{}
|
||||
|
||||
tool := agent.FunctionTool[Params](
|
||||
"compute",
|
||||
"Compute something",
|
||||
@@ -1440,6 +1465,7 @@ func TestRun_ToolUseBehavior(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct{}
|
||||
|
||||
tool := agent.FunctionTool[Params](
|
||||
"noop",
|
||||
"No-op",
|
||||
@@ -1482,6 +1508,7 @@ func TestRun_ToolUseBehavior(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct{}
|
||||
|
||||
tool := agent.FunctionTool[Params](
|
||||
"compute",
|
||||
"Compute something",
|
||||
@@ -1594,6 +1621,7 @@ func TestRun_Approval(t *testing.T) {
|
||||
)
|
||||
|
||||
require.Error(t, err)
|
||||
|
||||
var interrupted *agent.InterruptedError
|
||||
require.ErrorAs(t, err, &interrupted)
|
||||
assert.Len(t, interrupted.ToolCalls, 1)
|
||||
@@ -2107,8 +2135,10 @@ func TestRunStreamed(t *testing.T) {
|
||||
[]llm.Message{userMessage("Hi")},
|
||||
)
|
||||
|
||||
var deltas []string
|
||||
var gotComplete bool
|
||||
var (
|
||||
deltas []string
|
||||
gotComplete bool
|
||||
)
|
||||
|
||||
for ev := range sr.Events {
|
||||
switch ev.Type {
|
||||
@@ -2134,6 +2164,7 @@ func TestRunStreamed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct{}
|
||||
|
||||
tool := agent.FunctionTool[Params](
|
||||
"noop",
|
||||
"No-op",
|
||||
@@ -2186,6 +2217,7 @@ func TestRunStreamed(t *testing.T) {
|
||||
)
|
||||
|
||||
var gotToolStart, gotToolEnd, gotComplete bool
|
||||
|
||||
for ev := range sr.Events {
|
||||
switch ev.Type {
|
||||
case agent.StreamEventToolStart:
|
||||
@@ -2238,6 +2270,7 @@ func TestRunStreamed(t *testing.T) {
|
||||
)
|
||||
|
||||
var gotComplete, gotError bool
|
||||
|
||||
for ev := range sr.Events {
|
||||
switch ev.Type {
|
||||
case agent.StreamEventComplete:
|
||||
@@ -2287,22 +2320,29 @@ func TestRunStreamed(t *testing.T) {
|
||||
)
|
||||
|
||||
var collected []agent.StreamEvent
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
for ev := range sr.Events {
|
||||
collected = append(collected, ev)
|
||||
}
|
||||
}()
|
||||
|
||||
result, err := sr.Wait()
|
||||
|
||||
<-done
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Hello world!", result.FinalMessage().Text())
|
||||
|
||||
var deltaCount int
|
||||
var gotAgentStart, gotAgentEnd, gotComplete bool
|
||||
var (
|
||||
deltaCount int
|
||||
gotAgentStart, gotAgentEnd, gotComplete bool
|
||||
)
|
||||
|
||||
for _, ev := range collected {
|
||||
switch ev.Type {
|
||||
case agent.StreamEventLLMDelta:
|
||||
@@ -2359,6 +2399,7 @@ func TestClone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct{}
|
||||
|
||||
tool1 := agent.FunctionTool[Params](
|
||||
"t1",
|
||||
"desc",
|
||||
@@ -2474,6 +2515,7 @@ func TestGenerateSchema_EmbeddedStruct(t *testing.T) {
|
||||
ID string `json:"id" jsonschema:"unique identifier"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
type Params struct {
|
||||
Base
|
||||
Name string `json:"name"`
|
||||
@@ -2653,6 +2695,7 @@ func TestRun_UnknownToolCall(t *testing.T) {
|
||||
}
|
||||
|
||||
type Params struct{}
|
||||
|
||||
tool := agent.FunctionTool[Params](
|
||||
"real_tool",
|
||||
"A real tool",
|
||||
@@ -2746,6 +2789,7 @@ func TestClone_WithApprovalConfig(t *testing.T) {
|
||||
)
|
||||
|
||||
require.Error(t, err)
|
||||
|
||||
var interrupted *agent.InterruptedError
|
||||
require.ErrorAs(t, err, &interrupted)
|
||||
assert.Len(t, interrupted.PendingApprovals, 1)
|
||||
@@ -2771,6 +2815,7 @@ func TestRun_HandoffWithPreHandoffTools(t *testing.T) {
|
||||
var executionOrder []string
|
||||
|
||||
type Params struct{}
|
||||
|
||||
tool1 := agent.FunctionTool[Params](
|
||||
"prepare",
|
||||
"Prepare data",
|
||||
@@ -2830,6 +2875,7 @@ func TestRun_HandoffWithPreHandoffTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct{}
|
||||
|
||||
tool1 := agent.FunctionTool[Params](
|
||||
"prepare",
|
||||
"Prepare data",
|
||||
@@ -2892,6 +2938,7 @@ func TestRun_HandoffWithPreHandoffTools(t *testing.T) {
|
||||
assert.Equal(t, "specialist", result.LastAgent.Name())
|
||||
|
||||
var toolMsgs []llm.Message
|
||||
|
||||
for _, m := range result.Messages {
|
||||
if m.Role == llm.RoleTool {
|
||||
toolMsgs = append(toolMsgs, m)
|
||||
@@ -2914,6 +2961,7 @@ func TestRun_HandoffWithPreHandoffTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct{}
|
||||
|
||||
failingTool := agent.FunctionTool[Params](
|
||||
"prepare",
|
||||
"Prepare data",
|
||||
|
||||
@@ -47,6 +47,7 @@ func agentToolDepth(ctx context.Context) int {
|
||||
if v, ok := ctx.Value(agentToolDepthKey{}).(int); ok {
|
||||
return v
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -124,6 +125,7 @@ func (t *agentTool) Execute(ctx context.Context, arguments string) (ToolResult,
|
||||
if len(preview) > 500 {
|
||||
preview = preview[:500] + "... (truncated)"
|
||||
}
|
||||
|
||||
return ToolResult{
|
||||
Content: fmt.Sprintf("Sub-agent %q returned invalid JSON. Raw output:\n%s", t.agent.name, preview),
|
||||
IsError: true,
|
||||
|
||||
@@ -273,12 +273,14 @@ func TestAgentTool_Execute(t *testing.T) {
|
||||
var captured string
|
||||
|
||||
type Params struct{}
|
||||
|
||||
tenantTool := agent.FunctionTool[Params](
|
||||
"get_tenant",
|
||||
"Get tenant",
|
||||
func(ctx context.Context, _ Params) (agent.ToolResult, error) {
|
||||
rc := agent.RunContextFrom[*AppCtx](ctx)
|
||||
captured = rc.TenantID
|
||||
|
||||
return agent.ToolResult{Content: rc.TenantID}, nil
|
||||
},
|
||||
)
|
||||
@@ -483,6 +485,7 @@ func TestAgentTool_Execute_NestedApproval(t *testing.T) {
|
||||
)
|
||||
|
||||
require.Error(t, err)
|
||||
|
||||
var interrupted *agent.InterruptedError
|
||||
require.ErrorAs(t, err, &interrupted)
|
||||
assert.Len(t, interrupted.PendingApprovals, 1)
|
||||
@@ -605,6 +608,7 @@ func TestAgentTool_Execute_NestedApproval(t *testing.T) {
|
||||
var siblingCalled bool
|
||||
|
||||
type Params struct{}
|
||||
|
||||
siblingTool := agent.FunctionTool[Params](
|
||||
"list_files",
|
||||
"List files",
|
||||
@@ -740,6 +744,7 @@ func TestAgentTool_Execute_NestedApproval(t *testing.T) {
|
||||
)
|
||||
|
||||
require.Error(t, err)
|
||||
|
||||
var interrupted *agent.InterruptedError
|
||||
require.ErrorAs(t, err, &interrupted)
|
||||
assert.Equal(t, "agent_c", interrupted.Agent.Name())
|
||||
|
||||
@@ -47,6 +47,7 @@ func buildToolNameSet(names []string) map[string]struct{} {
|
||||
for _, name := range names {
|
||||
set[name] = struct{}{}
|
||||
}
|
||||
|
||||
return set
|
||||
}
|
||||
|
||||
@@ -60,5 +61,6 @@ func (c *ApprovalConfig) requiresApproval(ctx context.Context, tc llm.ToolCall)
|
||||
}
|
||||
|
||||
_, ok := c.toolNameSet[tc.Function.Name]
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ func TestBuildToolNameSet(t *testing.T) {
|
||||
set := buildToolNameSet([]string{"delete", "update", "create"})
|
||||
|
||||
assert.Len(t, set, 3)
|
||||
|
||||
for _, name := range []string{"delete", "update", "create"} {
|
||||
_, ok := set[name]
|
||||
assert.True(t, ok, "expected set to contain %q", name)
|
||||
@@ -165,15 +166,19 @@ func TestApprovalConfig_RequiresApproval(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
ctx := context.WithValue(context.Background(), ctxKey{}, "marker")
|
||||
|
||||
var capturedCtx context.Context
|
||||
var capturedTC llm.ToolCall
|
||||
var (
|
||||
capturedCtx context.Context
|
||||
capturedTC llm.ToolCall
|
||||
)
|
||||
|
||||
c := &ApprovalConfig{
|
||||
ShouldApprove: func(ctx context.Context, tc llm.ToolCall) bool {
|
||||
capturedCtx = ctx
|
||||
capturedTC = tc
|
||||
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ func (p *blockingProvider) ChatCompletion(ctx context.Context, _ *llm.ChatComple
|
||||
p.ctxAtEnd = ctx.Err()
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
return p.response, nil
|
||||
}
|
||||
|
||||
@@ -214,6 +215,7 @@ func TestRun_CtxCancelGracefulSuspend(t *testing.T) {
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
_, err := ag.Run(
|
||||
ctx,
|
||||
@@ -230,6 +232,7 @@ func TestRun_CtxCancelGracefulSuspend(t *testing.T) {
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("LLM call never started")
|
||||
}
|
||||
|
||||
cancel()
|
||||
close(provider.release)
|
||||
|
||||
|
||||
@@ -49,5 +49,6 @@ func TryRunContextFrom[C any](ctx context.Context) (C, bool) {
|
||||
}
|
||||
|
||||
typed, ok := val.(C)
|
||||
|
||||
return typed, ok
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ func (g *PromptInjectionGuardrail) Check(ctx context.Context, messages []llm.Mes
|
||||
"prompt injection classifier failed, allowing message through",
|
||||
log.Error(err),
|
||||
)
|
||||
|
||||
return &agent.GuardrailResult{Tripwire: false}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ func NewSystemPromptLeakGuardrail(fingerprints []string) *SystemPromptLeakGuardr
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
lowered = append(lowered, strings.ToLower(f))
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ func HandoffTo(agent *Agent, opts ...HandoffOption) *Handoff {
|
||||
for _, opt := range opts {
|
||||
opt(h)
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -84,6 +85,7 @@ func (h *Handoff) toolName() string {
|
||||
if h.ToolName != "" {
|
||||
return h.ToolName
|
||||
}
|
||||
|
||||
return "transfer_to_" + sanitizeToolName(h.Agent.name)
|
||||
}
|
||||
|
||||
|
||||
@@ -156,11 +156,13 @@ func TestWithHandoffInputFilter(t *testing.T) {
|
||||
target,
|
||||
agent.WithHandoffInputFilter(func(data agent.HandoffInputData) []llm.Message {
|
||||
var filtered []llm.Message
|
||||
|
||||
for _, m := range data.NewItems {
|
||||
if m.Role == llm.RoleUser {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}),
|
||||
)
|
||||
@@ -194,6 +196,7 @@ func TestWithHandoffInputFilter(t *testing.T) {
|
||||
all := make([]llm.Message, 0, len(data.InputHistory)+len(data.NewItems))
|
||||
all = append(all, data.InputHistory...)
|
||||
all = append(all, data.NewItems...)
|
||||
|
||||
return all
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -56,12 +56,15 @@ func (s *MCPServer) Name() string {
|
||||
|
||||
func (s *MCPServer) Tools(ctx context.Context) ([]Tool, error) {
|
||||
s.mu.RLock()
|
||||
|
||||
if s.toolsCached {
|
||||
cp := make([]Tool, len(s.cachedTools))
|
||||
copy(cp, s.cachedTools)
|
||||
s.mu.RUnlock()
|
||||
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
s.mu.RUnlock()
|
||||
|
||||
s.mu.Lock()
|
||||
@@ -70,11 +73,14 @@ func (s *MCPServer) Tools(ctx context.Context) ([]Tool, error) {
|
||||
if s.toolsCached {
|
||||
cp := make([]Tool, len(s.cachedTools))
|
||||
copy(cp, s.cachedTools)
|
||||
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
var allTools []*mcp.Tool
|
||||
var cursor string
|
||||
var (
|
||||
allTools []*mcp.Tool
|
||||
cursor string
|
||||
)
|
||||
|
||||
for {
|
||||
params := &mcp.ListToolsParams{}
|
||||
@@ -92,6 +98,7 @@ func (s *MCPServer) Tools(ctx context.Context) ([]Tool, error) {
|
||||
if result.NextCursor == "" {
|
||||
break
|
||||
}
|
||||
|
||||
cursor = result.NextCursor
|
||||
}
|
||||
|
||||
@@ -172,6 +179,7 @@ func extractMCPContent(result *mcp.CallToolResult) string {
|
||||
}
|
||||
|
||||
var parts []string
|
||||
|
||||
for _, c := range result.Content {
|
||||
if tc, ok := c.(*mcp.TextContent); ok {
|
||||
parts = append(parts, tc.Text)
|
||||
|
||||
@@ -106,6 +106,7 @@ func TestMCPServer_Tools(t *testing.T) {
|
||||
|
||||
// Mutating one slice must not affect the other.
|
||||
tools1[0] = nil
|
||||
|
||||
assert.NotNil(t, tools2[0])
|
||||
|
||||
// Underlying cache must be untouched.
|
||||
@@ -131,6 +132,7 @@ func TestMCPServer_Tools(t *testing.T) {
|
||||
s.toolsCached = true
|
||||
|
||||
const goroutines = 50
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
|
||||
@@ -212,12 +214,14 @@ func TestMCPServer_ResetCache(t *testing.T) {
|
||||
s.toolsCached = true
|
||||
|
||||
const goroutines = 50
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
|
||||
for range goroutines {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
s.ResetCache()
|
||||
}()
|
||||
}
|
||||
@@ -321,6 +325,7 @@ func TestExtractMCPContent(t *testing.T) {
|
||||
"empty content returns empty",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := &mcp.CallToolResult{Content: []mcp.Content{}}
|
||||
assert.Equal(t, "", extractMCPContent(result))
|
||||
},
|
||||
@@ -330,6 +335,7 @@ func TestExtractMCPContent(t *testing.T) {
|
||||
"single text content",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: "hello world"},
|
||||
@@ -343,6 +349,7 @@ func TestExtractMCPContent(t *testing.T) {
|
||||
"multiple text contents joined by newline",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: "line one"},
|
||||
@@ -357,6 +364,7 @@ func TestExtractMCPContent(t *testing.T) {
|
||||
"non-text content is skipped",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: "text part"},
|
||||
@@ -371,6 +379,7 @@ func TestExtractMCPContent(t *testing.T) {
|
||||
"only non-text content returns empty",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.ImageContent{Data: []byte("base64data"), MIMEType: "image/png"},
|
||||
@@ -384,6 +393,7 @@ func TestExtractMCPContent(t *testing.T) {
|
||||
"falls back to structured content when no text content",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := &mcp.CallToolResult{
|
||||
StructuredContent: map[string]any{
|
||||
"status": "ok",
|
||||
@@ -400,6 +410,7 @@ func TestExtractMCPContent(t *testing.T) {
|
||||
"text content takes precedence over structured content",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: "text wins"},
|
||||
@@ -414,6 +425,7 @@ func TestExtractMCPContent(t *testing.T) {
|
||||
"structured content used when content has only non-text",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.ImageContent{Data: []byte("img"), MIMEType: "image/png"},
|
||||
|
||||
@@ -75,6 +75,7 @@ func TestOutputType_responseFormat(t *testing.T) {
|
||||
|
||||
ot, err := NewOutputType[Verdict]("verdict")
|
||||
require.NoError(t, err)
|
||||
|
||||
rf := ot.responseFormat()
|
||||
|
||||
require.NotNil(t, rf)
|
||||
@@ -96,6 +97,7 @@ func TestOutputType_responseFormat_SchemaMatchesOutputType(t *testing.T) {
|
||||
|
||||
ot, err := NewOutputType[Analysis]("analysis")
|
||||
require.NoError(t, err)
|
||||
|
||||
rf := ot.responseFormat()
|
||||
|
||||
var schema map[string]any
|
||||
|
||||
@@ -37,13 +37,16 @@ func Restore(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot load checkpoint: %w", err)
|
||||
}
|
||||
|
||||
if cp == nil {
|
||||
return nil, fmt.Errorf("cannot restore: no checkpoint for run %s", runID)
|
||||
}
|
||||
|
||||
agent, err := registry.Agent(cp.AgentName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot resolve agent %q: %w", cp.AgentName, err)
|
||||
}
|
||||
|
||||
agent = applyCheckpointConfig(agent, cp.Config)
|
||||
|
||||
return restoreCheckpoint(ctx, agent, cp, store, runID, registry)
|
||||
@@ -58,6 +61,7 @@ func applyCheckpointConfig(agent *Agent, cfg AgentConfig) *Agent {
|
||||
if cfg.MaxTurns <= 0 {
|
||||
return agent
|
||||
}
|
||||
|
||||
return agent.Clone(WithMaxTurns(cfg.MaxTurns))
|
||||
}
|
||||
|
||||
@@ -154,13 +158,17 @@ func restoreNestedSuspended(
|
||||
}
|
||||
|
||||
entries := make([]nestedRestoreEntry, len(cp.AllToolCalls))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, tc := range cp.AllToolCalls {
|
||||
entries[i].toolCall = tc
|
||||
|
||||
result, ok := completedByID[tc.ID]
|
||||
if ok {
|
||||
entries[i].result = result
|
||||
entries[i].completed = true
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -169,6 +177,7 @@ func restoreNestedSuspended(
|
||||
entries[i].err = fmt.Errorf("cannot restore nested tool call %q: missing inner checkpoint", tc.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
entries[i].originalCheckpoint = innerCP
|
||||
|
||||
innerAgent, err := registry.Agent(innerCP.AgentName)
|
||||
@@ -176,9 +185,11 @@ func restoreNestedSuspended(
|
||||
entries[i].err = fmt.Errorf("cannot resolve inner agent %q: %w", innerCP.AgentName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
innerAgent = applyCheckpointConfig(innerAgent, innerCP.Config)
|
||||
|
||||
wg.Add(1)
|
||||
|
||||
go func(i int, tc llm.ToolCall, innerAgent *Agent, innerCP *Checkpoint) {
|
||||
defer wg.Done()
|
||||
|
||||
@@ -189,10 +200,14 @@ func restoreNestedSuspended(
|
||||
entries[i].err = fmt.Errorf("cannot restore nested tool call %q: missing suspension checkpoint", tc.ID)
|
||||
return
|
||||
}
|
||||
|
||||
entries[i].suspendedCheckpoint = se.Checkpoint
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
entries[i].err = fmt.Errorf("cannot restore nested tool call %q: %w", tc.ID, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -200,6 +215,7 @@ func restoreNestedSuspended(
|
||||
entries[i].completed = true
|
||||
}(i, tc, innerAgent, innerCP)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
messages := make([]llm.Message, len(cp.Messages))
|
||||
@@ -207,16 +223,20 @@ func restoreNestedSuspended(
|
||||
|
||||
completedCalls := make([]CompletedCall, 0, len(cp.AllToolCalls))
|
||||
remainingInner := make(map[string]*Checkpoint)
|
||||
|
||||
var restoreErr error
|
||||
|
||||
for _, entry := range entries {
|
||||
switch {
|
||||
case entry.err != nil:
|
||||
if entry.originalCheckpoint != nil {
|
||||
remainingInner[entry.toolCall.ID] = entry.originalCheckpoint
|
||||
}
|
||||
|
||||
if restoreErr == nil {
|
||||
restoreErr = entry.err
|
||||
}
|
||||
|
||||
continue
|
||||
|
||||
case entry.suspendedCheckpoint != nil:
|
||||
@@ -227,6 +247,7 @@ func restoreNestedSuspended(
|
||||
if restoreErr == nil {
|
||||
restoreErr = fmt.Errorf("cannot restore nested tool call %q: no result", entry.toolCall.ID)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -250,13 +271,16 @@ func restoreNestedSuspended(
|
||||
saveProgress := func() (*Checkpoint, error) {
|
||||
next := *cp
|
||||
next.InnerCheckpoints = remainingInner
|
||||
|
||||
next.CompletedCalls = completedCalls
|
||||
if store != nil && runID != "" {
|
||||
if err := store.Save(saveCtx, runID, &next); err != nil {
|
||||
return nil, fmt.Errorf("cannot save nested restore progress: %w", err)
|
||||
}
|
||||
|
||||
emitHook(agent, func(h RunHooks) { h.OnRunSnapshot(saveCtx, agent, &next) })
|
||||
}
|
||||
|
||||
return &next, nil
|
||||
}
|
||||
|
||||
@@ -264,6 +288,7 @@ func restoreNestedSuspended(
|
||||
if _, err := saveProgress(); err != nil {
|
||||
return nil, errors.Join(restoreErr, err)
|
||||
}
|
||||
|
||||
return nil, restoreErr
|
||||
}
|
||||
|
||||
@@ -272,6 +297,7 @@ func restoreNestedSuspended(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, &SuspendedError{RunID: runID, Checkpoint: next}
|
||||
}
|
||||
|
||||
@@ -301,11 +327,13 @@ func restoreAwaitingApproval(
|
||||
if len(cp.InnerCheckpoints) > 1 {
|
||||
return nil, fmt.Errorf("cannot restore approval checkpoint: expected one inner checkpoint, got %d", len(cp.InnerCheckpoints))
|
||||
}
|
||||
|
||||
for toolCallID, innerCP := range cp.InnerCheckpoints {
|
||||
innerAgent, err := registry.Agent(innerCP.AgentName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot resolve inner agent %q: %w", innerCP.AgentName, err)
|
||||
}
|
||||
|
||||
innerAgent = applyCheckpointConfig(innerAgent, innerCP.Config)
|
||||
|
||||
innerIE := &InterruptedError{
|
||||
@@ -334,6 +362,7 @@ func restoreAwaitingApproval(
|
||||
completedCalls: cp.CompletedCalls,
|
||||
innerInterrupt: innerIE,
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ func (s *memoryCheckpointer) Save(_ context.Context, runID string, cp *agent.Che
|
||||
|
||||
clone := *cp
|
||||
s.checkpoints[runID] = &clone
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -56,6 +57,7 @@ func (s *memoryCheckpointer) Load(_ context.Context, runID string) (*agent.Check
|
||||
}
|
||||
|
||||
clone := *cp
|
||||
|
||||
return &clone, nil
|
||||
}
|
||||
|
||||
@@ -68,6 +70,7 @@ func (r *simpleRegistry) Agent(name string) (*agent.Agent, error) {
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("agent %q not found", name)
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -220,6 +223,7 @@ func TestRestore(t *testing.T) {
|
||||
)
|
||||
|
||||
require.Error(t, err)
|
||||
|
||||
var interrupted *agent.InterruptedError
|
||||
require.ErrorAs(t, err, &interrupted)
|
||||
assert.Len(t, interrupted.PendingApprovals, 1)
|
||||
|
||||
@@ -29,5 +29,6 @@ func (r *Result) FinalMessage() llm.Message {
|
||||
if len(r.Messages) == 0 {
|
||||
return llm.Message{}
|
||||
}
|
||||
|
||||
return r.Messages[len(r.Messages)-1]
|
||||
}
|
||||
|
||||
@@ -104,14 +104,17 @@ func blockingCallLLM(ctx context.Context, agent *Agent, req *llm.ChatCompletionR
|
||||
if sErr != nil {
|
||||
return nil, err // return the original error
|
||||
}
|
||||
|
||||
defer func() { _ = stream.Close() }()
|
||||
|
||||
acc := llm.NewStreamAccumulator(stream)
|
||||
for acc.Next() {
|
||||
}
|
||||
|
||||
if sErr := acc.Err(); sErr != nil {
|
||||
return nil, sErr
|
||||
}
|
||||
|
||||
return acc.Response(), nil
|
||||
}
|
||||
|
||||
@@ -143,6 +146,7 @@ func (s *loopState) resolveAgentTools(ctx context.Context) error {
|
||||
|
||||
s.toolMap = toolMap
|
||||
s.toolDefs = toolDefs
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -218,6 +222,7 @@ func (s *loopState) finishRun(ctx context.Context, result *Result, err error) (*
|
||||
)
|
||||
|
||||
s.opts.onEvent(ctx, StreamEvent{Type: StreamEventComplete, Agent: s.agent, Result: result})
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
@@ -385,6 +390,7 @@ func coreLoop(ctx context.Context, startAgent *Agent, inputMessages []llm.Messag
|
||||
if s.opts.checkpointer != nil {
|
||||
if saveErr := s.opts.checkpointer.Save(ctx, s.opts.runID, cp); saveErr != nil {
|
||||
s.logger.ErrorCtx(ctx, "cannot save suspension checkpoint", log.Error(saveErr))
|
||||
|
||||
se.Checkpoint = cp
|
||||
} else {
|
||||
emitHook(s.agent, func(h RunHooks) { h.OnRunSnapshot(ctx, s.agent, cp) })
|
||||
@@ -412,6 +418,7 @@ func coreLoop(ctx context.Context, startAgent *Agent, inputMessages []llm.Messag
|
||||
if s.toolUsedInRun && s.agent.resetToolChoice && toolChoice != nil {
|
||||
toolChoice = nil
|
||||
}
|
||||
|
||||
if !exploring && structuredFormat != nil && len(s.toolDefs) > 0 {
|
||||
// On the synthesis turn, forbid further tool calls so the
|
||||
// model is forced to convert what it has into JSON.
|
||||
@@ -478,9 +485,11 @@ func coreLoop(ctx context.Context, startAgent *Agent, inputMessages []llm.Messag
|
||||
// conclusions during synthesis.
|
||||
if exploring && s.turns < s.agent.maxTurns {
|
||||
exploring = false
|
||||
|
||||
if resp.Message.Text() == "" {
|
||||
s.messages = s.messages[:len(s.messages)-1]
|
||||
}
|
||||
|
||||
s.messages = append(
|
||||
s.messages,
|
||||
llm.Message{
|
||||
@@ -494,6 +503,7 @@ func coreLoop(ctx context.Context, startAgent *Agent, inputMessages []llm.Messag
|
||||
log.Int("turn", s.turns),
|
||||
log.Int("output_tokens", resp.Usage.OutputTokens),
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -514,8 +524,10 @@ func coreLoop(ctx context.Context, startAgent *Agent, inputMessages []llm.Messag
|
||||
log.Int("retry", emptyOutputRetries),
|
||||
log.Int("output_tokens", resp.Usage.OutputTokens),
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if err := runOutputGuardrails(ctx, s.agent, resp.Message); err != nil {
|
||||
return s.finishRun(ctx, nil, err)
|
||||
}
|
||||
@@ -530,6 +542,7 @@ func coreLoop(ctx context.Context, startAgent *Agent, inputMessages []llm.Messag
|
||||
emitAgentHook(s.agent, func(h AgentHooks) { h.OnEnd(ctx, s.agent, resp.Message.Text()) })
|
||||
|
||||
opts.onEvent(ctx, StreamEvent{Type: StreamEventAgentEnd, Agent: s.agent})
|
||||
|
||||
return s.finishRun(ctx, result, nil)
|
||||
|
||||
case llm.FinishReasonToolCalls:
|
||||
@@ -561,6 +574,7 @@ func coreLoop(ctx context.Context, startAgent *Agent, inputMessages []llm.Messag
|
||||
outerCP.InnerCheckpoints = se.Checkpoint.InnerCheckpoints
|
||||
outerCP.CompletedCalls = se.Checkpoint.CompletedCalls
|
||||
}
|
||||
|
||||
if s.opts.checkpointer != nil {
|
||||
if saveErr := s.opts.checkpointer.Save(ctx, s.opts.runID, outerCP); saveErr != nil {
|
||||
s.logger.ErrorCtx(ctx, "cannot save checkpoint", log.Error(saveErr))
|
||||
@@ -568,6 +582,7 @@ func coreLoop(ctx context.Context, startAgent *Agent, inputMessages []llm.Messag
|
||||
emitHook(s.agent, func(h RunHooks) { h.OnRunSnapshot(ctx, s.agent, outerCP) })
|
||||
}
|
||||
}
|
||||
|
||||
return s.finishRun(ctx, nil, &SuspendedError{RunID: s.opts.runID, Checkpoint: outerCP})
|
||||
}
|
||||
|
||||
@@ -584,6 +599,7 @@ func coreLoop(ctx context.Context, startAgent *Agent, inputMessages []llm.Messag
|
||||
if s.opts.checkpointer != nil {
|
||||
cp := s.buildCheckpoint(AgentStatusAwaitingApproval)
|
||||
cp.PendingToolCalls = nae.allToolCalls
|
||||
|
||||
cp.PendingApprovals = nae.pendingApprovals
|
||||
if saveErr := s.opts.checkpointer.Save(ctx, s.opts.runID, cp); saveErr != nil {
|
||||
s.logger.ErrorCtx(ctx, "cannot save approval checkpoint", log.Error(saveErr))
|
||||
@@ -623,6 +639,7 @@ func coreLoop(ctx context.Context, startAgent *Agent, inputMessages []llm.Messag
|
||||
cp.PendingApprovals = nie.inner.PendingApprovals
|
||||
cp.AllToolCalls = nie.allToolCalls
|
||||
cp.CompletedCalls = nie.completedCalls
|
||||
|
||||
cp.InnerCheckpoints = map[string]*Checkpoint{
|
||||
nie.toolCallID: {
|
||||
Status: AgentStatusAwaitingApproval,
|
||||
@@ -692,6 +709,7 @@ func coreLoop(ctx context.Context, startAgent *Agent, inputMessages []llm.Messag
|
||||
emitAgentHook(s.agent, func(h AgentHooks) { h.OnEnd(ctx, s.agent, finalOutput) })
|
||||
|
||||
opts.onEvent(ctx, StreamEvent{Type: StreamEventAgentEnd, Agent: s.agent})
|
||||
|
||||
return s.finishRun(ctx, result, nil)
|
||||
}
|
||||
|
||||
@@ -748,6 +766,7 @@ func callLLMWithHooks(
|
||||
if err != nil {
|
||||
emitHook(agent, func(h RunHooks) { h.OnLLMEnd(ctx, agent, nil, err) })
|
||||
emitAgentHook(agent, func(h AgentHooks) { h.OnLLMEnd(ctx, agent, nil, err) })
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -790,6 +809,7 @@ func executeToolCalls(
|
||||
if !ok {
|
||||
return nil, nil, nil, fmt.Errorf("cannot dispatch tool call: unknown tool %q", tc.Function.Name)
|
||||
}
|
||||
|
||||
descriptors[i] = desc
|
||||
if _, isHandoff := desc.(*handoffToolAdapter); isHandoff && handoffIdx == -1 {
|
||||
handoffIdx = i
|
||||
@@ -806,6 +826,7 @@ func executeToolCalls(
|
||||
}
|
||||
|
||||
results, msgs, err := executeParallel(ctx, tracer, agent, toolCalls, tools, onEvent, logger)
|
||||
|
||||
return nil, results, msgs, err
|
||||
}
|
||||
|
||||
@@ -844,6 +865,7 @@ func executeWithHandoff(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return nil, nil, msgs, &nestedInterruptionError{
|
||||
inner: ie,
|
||||
toolCallID: toolCalls[i].ID,
|
||||
@@ -851,6 +873,7 @@ func executeWithHandoff(
|
||||
completedCalls: completed,
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil, msgs, err
|
||||
}
|
||||
|
||||
@@ -915,9 +938,11 @@ func executeParallel(
|
||||
logger *log.Logger,
|
||||
) ([]ToolCallResult, []llm.Message, error) {
|
||||
entries := make([]parallelToolEntry, len(toolCalls))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(len(toolCalls))
|
||||
|
||||
for i := range toolCalls {
|
||||
go func(idx int, tc llm.ToolCall, tool Tool) {
|
||||
defer wg.Done()
|
||||
@@ -927,6 +952,7 @@ func executeParallel(
|
||||
entries[idx] = parallelToolEntry{err: err}
|
||||
return
|
||||
}
|
||||
|
||||
entries[idx] = parallelToolEntry{result: tr}
|
||||
}(i, toolCalls[i], tools[i])
|
||||
}
|
||||
@@ -940,10 +966,12 @@ func executeParallel(
|
||||
}
|
||||
|
||||
var completed []CompletedCall
|
||||
|
||||
for j, other := range entries {
|
||||
if j == i {
|
||||
continue
|
||||
}
|
||||
|
||||
if other.err != nil {
|
||||
completed = append(
|
||||
completed,
|
||||
@@ -955,8 +983,10 @@ func executeParallel(
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
completed = append(
|
||||
completed,
|
||||
CompletedCall{
|
||||
@@ -965,6 +995,7 @@ func executeParallel(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return nil, nil, &nestedInterruptionError{
|
||||
inner: ie,
|
||||
toolCallID: toolCalls[i].ID,
|
||||
@@ -978,15 +1009,18 @@ func executeParallel(
|
||||
if entry.err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
se, ok := errors.AsType[*SuspendedError](entry.err)
|
||||
if ok && se.Checkpoint != nil {
|
||||
innerCheckpoints := make(map[string]*Checkpoint)
|
||||
|
||||
var completed []CompletedCall
|
||||
|
||||
for j, other := range entries {
|
||||
if j == i {
|
||||
continue
|
||||
}
|
||||
|
||||
if other.err == nil {
|
||||
completed = append(
|
||||
completed,
|
||||
@@ -995,8 +1029,10 @@ func executeParallel(
|
||||
Result: other.result,
|
||||
},
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
otherSE, ok := errors.AsType[*SuspendedError](other.err)
|
||||
if ok && otherSE.Checkpoint != nil {
|
||||
innerCheckpoints[toolCalls[j].ID] = otherSE.Checkpoint
|
||||
@@ -1024,6 +1060,7 @@ func executeParallel(
|
||||
CompletedCalls: completed,
|
||||
},
|
||||
}
|
||||
|
||||
return nil, nil, outerSE
|
||||
}
|
||||
}
|
||||
@@ -1060,6 +1097,7 @@ func executeParallel(
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1169,6 +1207,7 @@ func executeSingleTool(
|
||||
if len(content) > 200 {
|
||||
content = content[:200] + "... (truncated)"
|
||||
}
|
||||
|
||||
logger.WarnCtx(
|
||||
ctx,
|
||||
"tool returned error",
|
||||
@@ -1192,6 +1231,7 @@ func checkApproval(ctx context.Context, a *Agent, toolCalls []llm.ToolCall) erro
|
||||
}
|
||||
|
||||
var pending []llm.ToolCall
|
||||
|
||||
for _, tc := range toolCalls {
|
||||
if a.approval.requiresApproval(ctx, tc) {
|
||||
pending = append(pending, tc)
|
||||
@@ -1363,6 +1403,7 @@ func resumeWithOpts(ctx context.Context, interrupted *InterruptedError, input Re
|
||||
)
|
||||
|
||||
handoffTarget = ht.handoff
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1464,6 +1505,7 @@ func resumeNested(ctx context.Context, interrupted *InterruptedError, input Resu
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot resume nested agent: %w", err)
|
||||
}
|
||||
|
||||
@@ -1534,8 +1576,10 @@ func resolveStructuredFormat(a *Agent) *llm.ResponseFormat {
|
||||
if a.responseFormat != nil {
|
||||
return a.responseFormat
|
||||
}
|
||||
|
||||
if a.outputType != nil {
|
||||
return a.outputType.responseFormat()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ func mustJSONSchemaFor[T any]() json.RawMessage {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
@@ -63,6 +64,7 @@ func stripNullTypes(s *jsonschema.Schema) {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filtered) == 1 {
|
||||
s.Type = filtered[0]
|
||||
s.Types = nil
|
||||
|
||||
@@ -129,6 +129,7 @@ func TestGenerateSchema_NestedPointerStruct(t *testing.T) {
|
||||
type Inner struct {
|
||||
Value *string `json:"value"`
|
||||
}
|
||||
|
||||
type Params struct {
|
||||
Inner *Inner `json:"inner"`
|
||||
}
|
||||
@@ -251,12 +252,15 @@ func TestGenerateSchema_DeeplyNestedStructure(t *testing.T) {
|
||||
type Level3 struct {
|
||||
Value *int `json:"value"`
|
||||
}
|
||||
|
||||
type Level2 struct {
|
||||
Items []Level3 `json:"items"`
|
||||
}
|
||||
|
||||
type Level1 struct {
|
||||
Child *Level2 `json:"child"`
|
||||
}
|
||||
|
||||
type Params struct {
|
||||
Root Level1 `json:"root"`
|
||||
}
|
||||
@@ -299,6 +303,7 @@ func TestGenerateSchema_SliceOfStructs(t *testing.T) {
|
||||
Name string `json:"name"`
|
||||
Count *int `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type Params struct {
|
||||
Items []Item `json:"items"`
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ func (s *memorySession) Save(_ context.Context, sessionID string, messages []llm
|
||||
}
|
||||
|
||||
s.sessions[sessionID] = cp
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ You can transfer the conversation to a more specialized agent when appropriate:
|
||||
|
||||
func buildSystemPrompt(data systemPromptData) string {
|
||||
var buf bytes.Buffer
|
||||
|
||||
_ = systemPromptTmpl.Execute(&buf, data)
|
||||
|
||||
return buf.String()
|
||||
|
||||
@@ -50,6 +50,7 @@ func ResultJSON(v any) ToolResult {
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
return ToolResult{Content: string(data)}
|
||||
}
|
||||
|
||||
@@ -128,6 +129,7 @@ func (t *functionTool[P]) Execute(ctx context.Context, arguments string) (ToolRe
|
||||
}
|
||||
|
||||
var missing []string
|
||||
|
||||
for _, f := range t.requiredFields {
|
||||
if _, ok := fields[f]; !ok {
|
||||
missing = append(missing, f)
|
||||
|
||||
@@ -193,6 +193,7 @@ func TestFunctionTool_Execute(t *testing.T) {
|
||||
}
|
||||
|
||||
var received string
|
||||
|
||||
tool := agent.FunctionTool(
|
||||
"weather",
|
||||
"Get weather",
|
||||
@@ -257,6 +258,7 @@ func TestFunctionTool_Execute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
type Params struct{}
|
||||
|
||||
tool := agent.FunctionTool(
|
||||
|
||||
@@ -42,6 +42,7 @@ func StopOnFirstTool() ToolUseBehavior {
|
||||
if len(results) == 0 {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
return results[0].Result.Content, true, nil
|
||||
}
|
||||
}
|
||||
@@ -53,12 +54,14 @@ func StopAtTools(names ...string) ToolUseBehavior {
|
||||
for _, n := range names {
|
||||
stopSet[n] = struct{}{}
|
||||
}
|
||||
|
||||
return func(_ context.Context, results []ToolCallResult) (string, bool, error) {
|
||||
for _, r := range results {
|
||||
if _, ok := stopSet[r.ToolName]; ok {
|
||||
return r.Result.Content, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", false, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ func (b *Browser) checkAlive() *agent.ToolResult {
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ func DownloadPDFTool() agent.Tool {
|
||||
ErrorDetail: fmt.Sprintf("cannot download PDF: %s", err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -95,6 +96,7 @@ func DownloadPDFTool() agent.Tool {
|
||||
ErrorDetail: fmt.Sprintf("cannot create temp dir: %s", err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||
|
||||
tmpFile := filepath.Join(tmpDir, "input.pdf")
|
||||
@@ -106,6 +108,7 @@ func DownloadPDFTool() agent.Tool {
|
||||
|
||||
// Get page count.
|
||||
conf := model.NewDefaultConfiguration()
|
||||
|
||||
pageCount, err := api.PageCountFile(tmpFile)
|
||||
if err != nil {
|
||||
return agent.ResultJSON(downloadPDFResult{
|
||||
@@ -130,15 +133,18 @@ func DownloadPDFTool() agent.Tool {
|
||||
|
||||
// Read all extracted content files.
|
||||
var sb strings.Builder
|
||||
|
||||
entries, _ := os.ReadDir(outDir)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(outDir, entry.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
sb.Write(content)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ func FetchRobotsTxtTool() agent.Tool {
|
||||
ErrorDetail: fmt.Sprintf("cannot fetch robots.txt: %s", err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -79,6 +80,7 @@ func FetchRobotsTxtTool() agent.Tool {
|
||||
}
|
||||
|
||||
var result robotsResult
|
||||
|
||||
result.Found = true
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
|
||||
@@ -73,6 +73,7 @@ func FetchSitemapTool() agent.Tool {
|
||||
ErrorDetail: fmt.Sprintf("cannot fetch sitemap: %s", err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -92,7 +93,9 @@ func FetchSitemapTool() agent.Tool {
|
||||
ErrorDetail: fmt.Sprintf("cannot decompress gzipped sitemap: %s", err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
defer func() { _ = gz.Close() }()
|
||||
|
||||
reader = gz
|
||||
}
|
||||
|
||||
@@ -125,6 +128,7 @@ func FetchSitemapTool() agent.Tool {
|
||||
|
||||
func parseSitemapXML(r io.Reader) ([]string, error) {
|
||||
var urls []string
|
||||
|
||||
decoder := xml.NewDecoder(r)
|
||||
|
||||
for {
|
||||
@@ -132,6 +136,7 @@ func parseSitemapXML(r io.Reader) ([]string, error) {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return urls, err
|
||||
}
|
||||
|
||||
@@ -119,7 +119,9 @@ func NewPinnedTransport() *http.Transport {
|
||||
|
||||
// Dial the first validated IP directly to prevent DNS rebinding.
|
||||
pinnedAddr := net.JoinHostPort(ips[0].IP.String(), port)
|
||||
|
||||
var d net.Dialer
|
||||
|
||||
return d.DialContext(ctx, network, pinnedAddr)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ func DiffDocumentsTool() agent.Tool {
|
||||
if labelA == "" {
|
||||
labelA = "document_a"
|
||||
}
|
||||
|
||||
labelB := p.LabelB
|
||||
if labelB == "" {
|
||||
labelB = "document_b"
|
||||
@@ -80,6 +81,7 @@ func DiffDocumentsTool() agent.Tool {
|
||||
if len(output) > maxDiffOutput {
|
||||
output = output[:maxDiffOutput] + "\n[... diff truncated]"
|
||||
}
|
||||
|
||||
result.UnifiedDiff = output
|
||||
}
|
||||
|
||||
@@ -114,6 +116,7 @@ func computeDiff(linesA, linesB []string, labelA, labelB string) diffOutput {
|
||||
for i := range dp {
|
||||
dp[i] = make([]int, n+1)
|
||||
}
|
||||
|
||||
for i := m - 1; i >= 0; i-- {
|
||||
for j := n - 1; j >= 0; j-- {
|
||||
if linesA[i] == linesB[j] {
|
||||
@@ -131,6 +134,7 @@ func computeDiff(linesA, linesB []string, labelA, labelB string) diffOutput {
|
||||
fmt.Fprintf(&sb, "--- %s\n+++ %s\n", labelA, labelB)
|
||||
|
||||
var added, removed int
|
||||
|
||||
i, j := 0, 0
|
||||
for i < m || j < n {
|
||||
if i < m && j < n && linesA[i] == linesB[j] {
|
||||
@@ -139,10 +143,12 @@ func computeDiff(linesA, linesB []string, labelA, labelB string) diffOutput {
|
||||
j++
|
||||
} else if j < n && (i >= m || dp[i][j+1] >= dp[i+1][j]) {
|
||||
sb.WriteString("+ " + linesB[j] + "\n")
|
||||
|
||||
added++
|
||||
j++
|
||||
} else if i < m {
|
||||
sb.WriteString("- " + linesA[i] + "\n")
|
||||
|
||||
removed++
|
||||
i++
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ func FirecrawlSearchTool(apiKey string) agent.Tool {
|
||||
if maxResults <= 0 {
|
||||
maxResults = 5
|
||||
}
|
||||
|
||||
if maxResults > 10 {
|
||||
maxResults = 10
|
||||
}
|
||||
@@ -111,6 +112,7 @@ func firecrawlSearch(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
@@ -118,6 +120,7 @@ func firecrawlSearch(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute search request: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
|
||||
@@ -91,6 +91,7 @@ func CheckGovernmentDBTool(apiKey string) agent.Tool {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
*s.target = append(
|
||||
*s.target,
|
||||
|
||||
@@ -28,6 +28,7 @@ type userAgentTransport struct {
|
||||
func (t *userAgentTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.Header.Set("User-Agent", "Probo-Agent/1.0")
|
||||
|
||||
return t.next.RoundTrip(r2)
|
||||
}
|
||||
|
||||
@@ -35,5 +36,6 @@ func newHTTPClient() *http.Client {
|
||||
client := httpclient.DefaultPooledClient()
|
||||
client.Timeout = 15 * time.Second
|
||||
client.Transport = &userAgentTransport{next: client.Transport}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ func CheckWaybackTool() agent.Tool {
|
||||
|
||||
// Check availability.
|
||||
availURL := "https://archive.org/wayback/available?url=" + url.QueryEscape(p.URL)
|
||||
|
||||
body, err := httpGet(ctx, client, availURL)
|
||||
if err != nil {
|
||||
result.ErrorDetail = fmt.Sprintf("cannot check Wayback Machine availability: %s", err)
|
||||
@@ -118,6 +119,7 @@ func httpGet(ctx context.Context, client *http.Client, rawURL string) ([]byte, e
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
|
||||
@@ -101,6 +101,7 @@ func CheckCORSTool() agent.Tool {
|
||||
ErrorDetail: fmt.Sprintf("cannot fetch %s: %s", p.URL, err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
allowOrigin := resp.Header.Get("Access-Control-Allow-Origin")
|
||||
|
||||
@@ -92,6 +92,7 @@ func AnalyzeCSPTool() agent.Tool {
|
||||
ErrorDetail: fmt.Sprintf("cannot fetch %s: %s", p.URL, err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
raw := resp.Header.Get("Content-Security-Policy")
|
||||
@@ -111,6 +112,7 @@ func AnalyzeCSPTool() agent.Tool {
|
||||
directives := parseCSPDirectives(raw)
|
||||
|
||||
var hasUnsafeEval, hasUnsafeInline, hasWildcard bool
|
||||
|
||||
for _, d := range directives {
|
||||
for _, v := range d.Values {
|
||||
switch v {
|
||||
|
||||
@@ -46,6 +46,7 @@ func parseDMARCTag(record, tag string) string {
|
||||
return after
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -60,6 +61,7 @@ func CheckDMARCTool() agent.Tool {
|
||||
}
|
||||
|
||||
client := dns.NewClient()
|
||||
|
||||
answers, err := queryDNS(
|
||||
ctx,
|
||||
client,
|
||||
|
||||
@@ -53,10 +53,14 @@ func CheckDNSRecordsTool() agent.Tool {
|
||||
|
||||
hdr := dns.Header{Name: fqdn, Class: dns.ClassINET}
|
||||
client := dns.NewClient()
|
||||
var result dnsRecordsResult
|
||||
var errs []string
|
||||
|
||||
var (
|
||||
result dnsRecordsResult
|
||||
errs []string
|
||||
)
|
||||
|
||||
// A records.
|
||||
|
||||
if answers, err := queryDNS(ctx, client, &dns.A{Hdr: hdr}); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("A query failed: %s", err))
|
||||
} else {
|
||||
@@ -148,12 +152,14 @@ func queryDNS(ctx context.Context, client *dns.Client, question dns.RR, opts ...
|
||||
for _, opt := range opts {
|
||||
opt(&msg.MsgHeader)
|
||||
}
|
||||
|
||||
msg.Question = []dns.RR{question}
|
||||
|
||||
resp, _, err := client.Exchange(ctx, msg, "udp", defaultResolverAddr)
|
||||
if err == nil && resp.Truncated {
|
||||
resp, _, err = client.Exchange(ctx, msg, "tcp", defaultResolverAddr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ func CheckDNSSECTool() agent.Tool {
|
||||
}
|
||||
|
||||
client := dns.NewClient()
|
||||
|
||||
answers, err := queryDNS(
|
||||
ctx,
|
||||
client,
|
||||
@@ -66,8 +67,11 @@ func CheckDNSSECTool() agent.Tool {
|
||||
}), nil
|
||||
}
|
||||
|
||||
var keyCount int
|
||||
var keyDetails []string
|
||||
var (
|
||||
keyCount int
|
||||
keyDetails []string
|
||||
)
|
||||
|
||||
for _, answer := range answers {
|
||||
if key, ok := answer.(*dns.DNSKEY); ok {
|
||||
keyCount++
|
||||
@@ -76,6 +80,7 @@ func CheckDNSSECTool() agent.Tool {
|
||||
if key.Flags&0x0001 != 0 {
|
||||
flags = "KSK"
|
||||
}
|
||||
|
||||
keyDetails = append(
|
||||
keyDetails,
|
||||
fmt.Sprintf("%s (algorithm=%d, flags=%d)", flags, key.Algorithm, key.Flags),
|
||||
|
||||
@@ -52,6 +52,7 @@ type (
|
||||
|
||||
func checkHeader(h http.Header, name string) headerCheck {
|
||||
v := h.Get(name)
|
||||
|
||||
return headerCheck{
|
||||
Present: v != "",
|
||||
Value: v,
|
||||
@@ -92,6 +93,7 @@ func CheckSecurityHeadersTool() agent.Tool {
|
||||
|
||||
// First check the HTTP version to detect HTTP→HTTPS redirect.
|
||||
redirectsToHTTPS := false
|
||||
|
||||
httpURL := p.URL
|
||||
if after, ok := strings.CutPrefix(httpURL, "https://"); ok {
|
||||
httpURL = "http://" + after
|
||||
@@ -118,18 +120,21 @@ func CheckSecurityHeadersTool() agent.Tool {
|
||||
}
|
||||
|
||||
followClient := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
httpsReq, err := http.NewRequestWithContext(ctx, http.MethodGet, httpsURL, nil)
|
||||
if err != nil {
|
||||
return agent.ResultJSON(headersResult{
|
||||
ErrorDetail: fmt.Sprintf("cannot create request for %s: %s", httpsURL, err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
resp, err := followClient.Do(httpsReq)
|
||||
if err != nil {
|
||||
return agent.ResultJSON(headersResult{
|
||||
ErrorDetail: fmt.Sprintf("cannot fetch %s: %s", httpsURL, err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
result := headersFromResponse(resp)
|
||||
|
||||
@@ -81,6 +81,7 @@ func CheckBreachesTool() agent.Tool {
|
||||
ErrorDetail: fmt.Sprintf("cannot fetch breaches: %s", err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
|
||||
@@ -26,6 +26,7 @@ func resolverAddr() string {
|
||||
if addr := os.Getenv("DNS_RESOLVER_ADDR"); addr != "" {
|
||||
return addr
|
||||
}
|
||||
|
||||
return "8.8.8.8:53"
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ func CheckSPFTool() agent.Tool {
|
||||
}
|
||||
|
||||
client := dns.NewClient()
|
||||
|
||||
answers, err := queryDNS(
|
||||
ctx,
|
||||
client,
|
||||
@@ -83,6 +84,7 @@ func CheckSPFTool() agent.Tool {
|
||||
}
|
||||
|
||||
var spfRecords []string
|
||||
|
||||
for _, answer := range answers {
|
||||
txt, ok := answer.(*dns.TXT)
|
||||
if !ok {
|
||||
@@ -106,6 +108,7 @@ func CheckSPFTool() agent.Tool {
|
||||
|
||||
if len(spfRecords) == 1 {
|
||||
record := spfRecords[0]
|
||||
|
||||
return agent.ResultJSON(spfResult{
|
||||
Found: true,
|
||||
RawRecord: record,
|
||||
|
||||
@@ -89,16 +89,19 @@ func CheckSSLCertificateTool() agent.Tool {
|
||||
},
|
||||
}
|
||||
netConn, err := dialer.DialContext(ctx, "tcp", p.Domain+":443")
|
||||
|
||||
var conn *tls.Conn
|
||||
if netConn != nil {
|
||||
conn = netConn.(*tls.Conn)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return agent.ResultJSON(sslResult{
|
||||
Valid: false,
|
||||
ErrorDetail: err.Error(),
|
||||
}), nil
|
||||
}
|
||||
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
state := conn.ConnectionState()
|
||||
@@ -124,6 +127,7 @@ func CheckSSLCertificateTool() agent.Tool {
|
||||
for _, ic := range state.PeerCertificates[1:] {
|
||||
opts.Intermediates.AddCert(ic)
|
||||
}
|
||||
|
||||
if _, err := cert.Verify(opts); err != nil {
|
||||
valid = false
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ func CheckWhoisTool() agent.Tool {
|
||||
if whoisServer == "" {
|
||||
whoisServer = parseWhoisField(referral, "whois")
|
||||
}
|
||||
|
||||
if whoisServer == "" {
|
||||
// Try common TLD WHOIS servers as fallback.
|
||||
parts := strings.Split(p.Domain, ".")
|
||||
@@ -84,6 +85,7 @@ func CheckWhoisTool() agent.Tool {
|
||||
if whoisHost == "" {
|
||||
whoisHost = whoisServer
|
||||
}
|
||||
|
||||
if err := netcheck.ValidatePublicDomain(whoisHost); err != nil {
|
||||
return agent.ResultJSON(whoisResult{
|
||||
ErrorDetail: fmt.Sprintf("WHOIS referral server not allowed: %s", err),
|
||||
@@ -114,6 +116,7 @@ func CheckWhoisTool() agent.Tool {
|
||||
years := int(age.Hours() / 24 / 365)
|
||||
months := int(age.Hours()/24/30) % 12
|
||||
result.DomainAge = fmt.Sprintf("%d years, %d months", years, months)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -126,10 +129,12 @@ func CheckWhoisTool() agent.Tool {
|
||||
|
||||
func queryWhois(ctx context.Context, server, domain string) (string, error) {
|
||||
dialer := net.Dialer{Timeout: 10 * time.Second}
|
||||
|
||||
conn, err := dialer.DialContext(ctx, "tcp", server)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot connect to %s: %w", server, err)
|
||||
}
|
||||
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
@@ -140,11 +145,13 @@ func queryWhois(ctx context.Context, server, domain string) (string, error) {
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
scanner := bufio.NewScanner(conn)
|
||||
for scanner.Scan() {
|
||||
sb.WriteString(scanner.Text())
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", fmt.Errorf("cannot read from %s: %w", server, err)
|
||||
}
|
||||
@@ -154,19 +161,23 @@ func queryWhois(ctx context.Context, server, domain string) (string, error) {
|
||||
|
||||
func parseWhoisField(raw, field string) string {
|
||||
field = strings.ToLower(field)
|
||||
|
||||
for line := range strings.SplitSeq(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "%") || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
k, v, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.ToLower(strings.TrimSpace(k)) == field {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -199,16 +210,20 @@ var (
|
||||
|
||||
func parseWhoisResponse(raw string) whoisResult {
|
||||
var result whoisResult
|
||||
|
||||
for line := range strings.SplitSeq(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "%") || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
k, v, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.ToLower(strings.TrimSpace(k))
|
||||
|
||||
val := strings.TrimSpace(v)
|
||||
if val == "" {
|
||||
continue
|
||||
|
||||
@@ -33,8 +33,10 @@ func (m *typedMockProvider) ChatCompletion(_ context.Context, _ *llm.ChatComplet
|
||||
if m.calls >= len(m.responses) {
|
||||
return nil, errors.New("no more mock responses")
|
||||
}
|
||||
|
||||
resp := m.responses[m.calls]
|
||||
m.calls++
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user