Add wsl linter and fix

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-05-19 14:51:08 +04:00
parent eedfdcecc8
commit 9156d6a16a
882 changed files with 6068 additions and 574 deletions

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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,

View File

@@ -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())

View File

@@ -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
}

View File

@@ -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
},
}

View File

@@ -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)

View File

@@ -49,5 +49,6 @@ func TryRunContextFrom[C any](ctx context.Context) (C, bool) {
}
typed, ok := val.(C)
return typed, ok
}

View File

@@ -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
}

View File

@@ -32,6 +32,7 @@ func NewSystemPromptLeakGuardrail(fingerprints []string) *SystemPromptLeakGuardr
if f == "" {
continue
}
lowered = append(lowered, strings.ToLower(f))
}

View File

@@ -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)
}

View File

@@ -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
}),
)

View File

@@ -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)

View File

@@ -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"},

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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]
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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"`
}

View File

@@ -61,6 +61,7 @@ func (s *memorySession) Save(_ context.Context, sessionID string, messages []llm
}
s.sessions[sessionID] = cp
return nil
}

View File

@@ -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()

View File

@@ -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)

View File

@@ -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(

View File

@@ -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
}
}

View File

@@ -123,6 +123,7 @@ func (b *Browser) checkAlive() *agent.ToolResult {
IsError: true,
}
}
return nil
}

View File

@@ -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")
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)
},
}

View File

@@ -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++
}

View File

@@ -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)

View File

@@ -91,6 +91,7 @@ func CheckGovernmentDBTool(apiKey string) agent.Tool {
if err != nil {
continue
}
for _, e := range entries {
*s.target = append(
*s.target,

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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),

View File

@@ -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)

View File

@@ -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)

View File

@@ -26,6 +26,7 @@ func resolverAddr() string {
if addr := os.Getenv("DNS_RESOLVER_ADDR"); addr != "" {
return addr
}
return "8.8.8.8:53"
}

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}