From 47c9dfb4854d23c733c391f7bb88ac98c5acb1bf Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sat, 25 Jul 2026 12:35:22 +0800 Subject: [PATCH 1/9] refactor(runner): remove unused run replay mechanisms --- core/runner/runtime_semantics_test.go | 22 ++++-- core/runner/runtime_session.go | 102 +------------------------- core/runner/subagent_handoff.go | 10 --- core/runner/subagent_handoff_test.go | 9 ++- 4 files changed, 23 insertions(+), 120 deletions(-) diff --git a/core/runner/runtime_semantics_test.go b/core/runner/runtime_semantics_test.go index 2b407599..e2b616f7 100644 --- a/core/runner/runtime_semantics_test.go +++ b/core/runner/runtime_semantics_test.go @@ -72,18 +72,21 @@ func TestSessionRunHasOneReliableTurnLifecycle(t *testing.T) { t.Fatal(err) } - var replay []aop.Event - for event := range run.Events(context.Background()) { - replay = append(replay, event) + var turnEvents []aop.Event + for _, event := range all { + if event.TurnID != "turn-1" { + continue + } + turnEvents = append(turnEvents, event) if event.SessionID != "session-1" || event.TurnID != "turn-1" { t.Fatalf("run event identity = %+v", event) } } - if len(replay) < 2 || replay[0].Type != aop.TypeTurnStart || replay[len(replay)-1].Type != aop.TypeTurnEnd { - t.Fatalf("run replay = %+v", replay) + if len(turnEvents) < 2 || turnEvents[0].Type != aop.TypeTurnStart || turnEvents[len(turnEvents)-1].Type != aop.TypeTurnEnd { + t.Fatalf("turn events = %+v", turnEvents) } starts, ends := 0, 0 - for _, event := range replay { + for _, event := range turnEvents { if event.Type == aop.TypeTurnStart { starts++ } @@ -183,6 +186,8 @@ func TestCommandAddsAOPHistoryWithoutChangingTranscript(t *testing.T) { func TestActiveRunSteersAsyncInputWithoutSecondLifecycle(t *testing.T) { provider := &runtimeSemanticProvider{started: make(chan struct{}), release: make(chan struct{})} rt := newBareRuntime(t, nil, provider) + var events []aop.Event + rt.Subscribe(func(event aop.Event) { events = append(events, event) }) session, err := rt.OpenSession(context.Background(), SessionOptions{ID: "session-1"}) if err != nil { t.Fatal(err) @@ -207,7 +212,10 @@ func TestActiveRunSteersAsyncInputWithoutSecondLifecycle(t *testing.T) { t.Fatalf("provider calls = %d, want 2 inside one Run", provider.callCount()) } starts, ends := 0, 0 - for event := range run.Events(context.Background()) { + for _, event := range events { + if event.TurnID != "turn-1" { + continue + } if event.Type == aop.TypeTurnStart { starts++ } diff --git a/core/runner/runtime_session.go b/core/runner/runtime_session.go index ac67be05..1e29502d 100644 --- a/core/runner/runtime_session.go +++ b/core/runner/runtime_session.go @@ -70,7 +70,6 @@ type Session struct { type Run struct { turnID string - log *runEventLog done chan struct{} mu sync.Mutex result RunResult @@ -84,15 +83,6 @@ func (r *Run) TurnID() string { return r.turnID } -func (r *Run) Events(ctx context.Context) <-chan aop.Event { - if r == nil || r.log == nil { - ch := make(chan aop.Event) - close(ch) - return ch - } - return r.log.events(ctx) -} - func (r *Run) Wait() (RunResult, error) { if r == nil { return RunResult{}, fmt.Errorf("run is nil") @@ -122,78 +112,6 @@ type commandOutcome struct { err error } -type runEventLog struct { - mu sync.Mutex - eventsLog []aop.Event - notify chan struct{} - closed bool -} - -func newRunEventLog() *runEventLog { - return &runEventLog{notify: make(chan struct{})} -} - -func (l *runEventLog) append(event aop.Event) { - l.mu.Lock() - if l.closed { - l.mu.Unlock() - return - } - l.eventsLog = append(l.eventsLog, event) - close(l.notify) - l.notify = make(chan struct{}) - l.mu.Unlock() -} - -func (l *runEventLog) close() { - l.mu.Lock() - if !l.closed { - l.closed = true - close(l.notify) - } - l.mu.Unlock() -} - -func (l *runEventLog) events(ctx context.Context) <-chan aop.Event { - if ctx == nil { - ctx = context.Background() - } - out := make(chan aop.Event) - go func() { - defer close(out) - index := 0 - for { - l.mu.Lock() - var event aop.Event - hasEvent := index < len(l.eventsLog) - if hasEvent { - event = l.eventsLog[index] - index++ - } - closed := l.closed - notify := l.notify - l.mu.Unlock() - if hasEvent { - select { - case out <- event: - case <-ctx.Done(): - return - } - continue - } - if closed { - return - } - select { - case <-notify: - case <-ctx.Done(): - return - } - } - }() - return out -} - type sessionEmitter struct { bus *eventbus.Bus[aop.Event] mu sync.Mutex @@ -225,17 +143,6 @@ type turnEmitter struct { turnID string agentName string emitter *sessionEmitter - log *runEventLog -} - -func (e *turnEmitter) observe(event aop.Event) { - if event.SessionID != e.sessionID || event.TurnID != e.turnID { - return - } - e.log.append(event) - if event.Type == aop.TypeTurnEnd { - e.log.close() - } } func (e *turnEmitter) start() { @@ -556,14 +463,11 @@ func (s *sessionState) startRun(ctx context.Context, input RunInput) (*Run, erro } s.runtime.turnIDs[turnID] = struct{}{} s.runtime.mu.Unlock() - log := newRunEventLog() - run := &Run{turnID: turnID, log: log, done: make(chan struct{})} - emitter := &turnEmitter{sessionID: s.id, turnID: turnID, agentName: s.agentName, emitter: s.runtime.sessionEvents, log: log} - unsubscribe := s.runtime.Subscribe(emitter.observe) + run := &Run{turnID: turnID, done: make(chan struct{})} + emitter := &turnEmitter{sessionID: s.id, turnID: turnID, agentName: s.agentName, emitter: s.runtime.sessionEvents} op := &sessionOperation{ execute: func(runCtx context.Context) { defer s.runtime.releaseTurnID(turnID) - defer unsubscribe() s.inbox.setActive(true) emitter.start() result, runErr := s.executeRun(runCtx, turnID, input) @@ -586,7 +490,6 @@ func (s *sessionState) startRun(ctx context.Context, input RunInput) (*Run, erro }, reject: func(err error) { defer s.runtime.releaseTurnID(turnID) - defer unsubscribe() result := RunResult{Stop: agent.StopReasonCanceled} if !errors.Is(err, context.Canceled) { result.Stop = agent.StopReasonError @@ -597,7 +500,6 @@ func (s *sessionState) startRun(ctx context.Context, input RunInput) (*Run, erro }, } if err := s.admit(ctx, op); err != nil { - unsubscribe() s.runtime.releaseTurnID(turnID) return nil, err } diff --git a/core/runner/subagent_handoff.go b/core/runner/subagent_handoff.go index 6343921e..6650676d 100644 --- a/core/runner/subagent_handoff.go +++ b/core/runner/subagent_handoff.go @@ -16,16 +16,6 @@ import ( "github.com/chainreactors/ioa/protocols" ) -// subscribeIOAHandoff records the two subagent lifecycle boundaries as native -// IOA handoff messages by observing the agent AOP bus: a child session.start -// carrying a delegation extension is the delegation, and the matching child -// session.end is the return. The return references the delegation message so -// other IOA implementations can reconstruct the thread without aiscan-specific -// APIs. -func subscribeIOAHandoff(bus *eventbus.Bus[aop.Event], client protocols.ClientAPI, spaceName string, logger telemetry.Logger) { - _ = subscribeIOAHandoffContext(context.Background(), bus, client, spaceName, logger) -} - func subscribeIOAHandoffContext(ctx context.Context, bus *eventbus.Bus[aop.Event], client protocols.ClientAPI, spaceName string, logger telemetry.Logger) func() { if bus == nil || client == nil || spaceName == "" { return func() {} diff --git a/core/runner/subagent_handoff_test.go b/core/runner/subagent_handoff_test.go index 65b20fd3..77adc5e5 100644 --- a/core/runner/subagent_handoff_test.go +++ b/core/runner/subagent_handoff_test.go @@ -72,7 +72,8 @@ func handoffEvent(t *testing.T, typ, sessionID, agentName string, data any) aop. func TestIOAHandoffFromAOPBus(t *testing.T) { client := &handoffClient{} bus := eventbus.New[aop.Event]() - subscribeIOAHandoff(bus, client, "test", nil) + cancel := subscribeIOAHandoffContext(context.Background(), bus, client, "test", nil) + defer cancel() start := handoffEvent(t, aop.TypeSessionStart, "child-session", "worker", aop.SessionStartData{ Model: "test-model", @@ -136,7 +137,8 @@ func TestIOAHandoffFromAOPBus(t *testing.T) { func TestIOAHandoffFailedRun(t *testing.T) { client := &handoffClient{} bus := eventbus.New[aop.Event]() - subscribeIOAHandoff(bus, client, "test", nil) + cancel := subscribeIOAHandoffContext(context.Background(), bus, client, "test", nil) + defer cancel() start := handoffEvent(t, aop.TypeSessionStart, "child-session", "worker", aop.SessionStartData{ ParentSessionID: "parent-session", @@ -165,7 +167,8 @@ func TestIOAHandoffFailedRun(t *testing.T) { func TestIOAHandoffIgnoresNonDelegationSessions(t *testing.T) { client := &handoffClient{} bus := eventbus.New[aop.Event]() - subscribeIOAHandoff(bus, client, "test", nil) + cancel := subscribeIOAHandoffContext(context.Background(), bus, client, "test", nil) + defer cancel() bus.Emit(handoffEvent(t, aop.TypeSessionStart, "root-session", "aiscan", aop.SessionStartData{Model: "test-model"})) bus.Emit(handoffEvent(t, aop.TypeTurnEnd, "root-session", "aiscan", aop.TurnEndData{Stop: "completed"})) From 37333fb59eb6be9007eb8fe6c820f757409e98f8 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sat, 25 Jul 2026 12:38:37 +0800 Subject: [PATCH 2/9] refactor(web): narrow chat events to domain events --- pkg/web/agents.go | 10 +++---- pkg/web/eval_forward_test.go | 4 +-- pkg/web/service.go | 33 +++++++++++------------- pkg/web/sse.go | 2 +- pkg/web/sse_test.go | 20 +++++++------- pkg/web/types.go | 16 +++++------- web/frontend/src/api.ts | 18 +++++-------- web/frontend/src/hooks/useChatSession.ts | 10 +++---- 8 files changed, 51 insertions(+), 62 deletions(-) diff --git a/pkg/web/agents.go b/pkg/web/agents.go index 9217bec9..3090d552 100644 --- a/pkg/web/agents.go +++ b/pkg/web/agents.go @@ -98,7 +98,7 @@ func (a *remoteAgent) commandSpecs() []webproto.CommandSpec { // SessionLookup resolves a task ID to its owning chat session. type SessionLookup interface { TaskSession(taskID string) (sessionID string, ok bool) - BroadcastChatEvent(sessionID string, event ChatEvent) + BroadcastDomainEvent(sessionID string, event DomainEvent) BroadcastAOPEvent(sessionID string, event aop.Event) } @@ -809,8 +809,8 @@ func (p *AgentPool) handleAgentMessage(a *remoteAgent, msg webproto.Message) { Type: "progress", Data: mustJSON(map[string]string{"scan_id": msg.TaskID, "data": data}), }) - p.forwardToSession(a, msg.TaskID, ChatEvent{ - Type: ChatEventScanProgress, + p.forwardToSession(a, msg.TaskID, DomainEvent{ + Type: DomainEventScanProgress, ScanID: msg.TaskID, Data: data, }) @@ -972,7 +972,7 @@ func (p *AgentPool) recordScanResultStats(a *remoteAgent, payload json.RawMessag a.mu.Unlock() } -func (p *AgentPool) forwardToSession(a *remoteAgent, taskID string, event ChatEvent) { +func (p *AgentPool) forwardToSession(a *remoteAgent, taskID string, event DomainEvent) { if p.sessions == nil || taskID == "" { return } @@ -986,7 +986,7 @@ func (p *AgentPool) forwardToSession(a *remoteAgent, taskID string, event ChatEv if event.AgentName == "" { event.AgentName = a.name } - p.sessions.BroadcastChatEvent(sid, event) + p.sessions.BroadcastDomainEvent(sid, event) } func (p *AgentPool) forwardAOPEvent(a *remoteAgent, msg webproto.Message) { diff --git a/pkg/web/eval_forward_test.go b/pkg/web/eval_forward_test.go index a9bfac84..78ca8d11 100644 --- a/pkg/web/eval_forward_test.go +++ b/pkg/web/eval_forward_test.go @@ -11,12 +11,12 @@ import ( type evalSink struct { sid string - chatEvents []ChatEvent + chatEvents []DomainEvent aopEvents []aop.Event } func (s *evalSink) TaskSession(string) (string, bool) { return s.sid, true } -func (s *evalSink) BroadcastChatEvent(_ string, event ChatEvent) { +func (s *evalSink) BroadcastDomainEvent(_ string, event DomainEvent) { s.chatEvents = append(s.chatEvents, event) } func (s *evalSink) BroadcastAOPEvent(_ string, event aop.Event) { diff --git a/pkg/web/service.go b/pkg/web/service.go index aeff029d..c4d51f14 100644 --- a/pkg/web/service.go +++ b/pkg/web/service.go @@ -1131,15 +1131,15 @@ func (s *Service) GetAOPEvents(ctx context.Context, sessionID string) ([]aop.Eve return s.store.ListAOPEvents(ctx, sessionID, 10000) } -func (s *Service) BroadcastChatEvent(sessionID string, event ChatEvent) { +func (s *Service) BroadcastDomainEvent(sessionID string, event DomainEvent) { event.SessionID = sessionID if !event.Transient { - s.persistRuntimeChatEvent(sessionID, event) + s.persistRuntimeDomainEvent(sessionID, event) } s.hub.Broadcast(sessionTopic(sessionID), HubEvent{ Type: event.Type, Data: mustJSON(event), - Reliable: isTerminalChatEvent(event.Type), + Reliable: isTerminalDomainEvent(event.Type), }) } @@ -1199,13 +1199,13 @@ func isReliableAOPEvent(event aop.Event) bool { return false } -// isTerminalChatEvent classifies terminal platform events. Agent run lifecycle +// isTerminalDomainEvent classifies terminal platform events. Agent run lifecycle // (including hub-originated failures) is carried exclusively by AOP. -func isTerminalChatEvent(t string) bool { - return t == ChatEventScanComplete +func isTerminalDomainEvent(t string) bool { + return t == DomainEventScanComplete } -func (s *Service) persistRuntimeChatEvent(sessionID string, event ChatEvent) { +func (s *Service) persistRuntimeDomainEvent(sessionID string, event DomainEvent) { if s == nil || s.store == nil || sessionID == "" { return } @@ -1221,12 +1221,9 @@ func (s *Service) persistRuntimeChatEvent(sessionID string, event ChatEvent) { metadata := map[string]any{ "event_type": event.Type, } - if event.Turn > 0 { - metadata["turn"] = event.Turn - } switch event.Type { - case ChatEventScanComplete: + case DomainEventScanComplete: // Persist a lightweight marker so the inline scan card survives a reload / // session switch. The heavy Result payload is NOT stored here — it stays // reloadable via the session_scans link (getScan), and the client fills the @@ -1356,7 +1353,7 @@ func (s *Service) handleClearCommand(sessionID string, opts webproto.GoalExt) { _ = s.store.ClearMessages(context.Background(), sessionID) // Transient: a live-only signal to connected clients — the cleared state is // already durable in the store, so a reconnecting client re-derives it on load. - s.BroadcastChatEvent(sessionID, ChatEvent{Type: ChatEventSessionCleared, Transient: true}) + s.BroadcastDomainEvent(sessionID, DomainEvent{Type: DomainEventSessionCleared, Transient: true}) if s.sessionAgent(sessionID) != nil { s.handleAgentCommand(sessionID, "/clear") } @@ -1477,8 +1474,8 @@ func (s *Service) handleScanCommand(sessionID, args string) { s.registerSessionTask(job.ID, sessionID, "") - s.BroadcastChatEvent(sessionID, ChatEvent{ - Type: ChatEventScanStarted, + s.BroadcastDomainEvent(sessionID, DomainEvent{ + Type: DomainEventScanStarted, ScanID: job.ID, Data: fmt.Sprintf("Scan started: %s (%s)", target, mode), }) @@ -1534,8 +1531,8 @@ func (s *Service) handleChatMessage(sessionID string, msg *ChatMessage, opts web taskID := generateID() s.registerSessionTask(taskID, sessionID, agent.id) - s.BroadcastChatEvent(sessionID, ChatEvent{ - Type: ChatEventAgentJoined, + s.BroadcastDomainEvent(sessionID, DomainEvent{ + Type: DomainEventAgentJoined, AgentID: agent.id, AgentName: agent.name, }) @@ -1643,8 +1640,8 @@ func (s *Service) broadcastScanComplete(scanID string, result *output.Result) { if s.finishSessionTask(scanID) { return } - s.BroadcastChatEvent(sid, ChatEvent{ - Type: ChatEventScanComplete, + s.BroadcastDomainEvent(sid, DomainEvent{ + Type: DomainEventScanComplete, ScanID: scanID, Result: result, }) diff --git a/pkg/web/sse.go b/pkg/web/sse.go index 5ba83ca1..49737eb2 100644 --- a/pkg/web/sse.go +++ b/pkg/web/sse.go @@ -18,7 +18,7 @@ type HubEvent struct { Data json.RawMessage // Reliable marks a terminal event that Broadcast must not drop under // backpressure: on a full buffer it evicts the oldest queued event to seat - // one, rather than shedding it like a token delta. See isTerminalChatEvent + // one, rather than shedding it like a token delta. See isTerminalDomainEvent // for which events qualify and why a lost one strands the UI. Reliable bool } diff --git a/pkg/web/sse_test.go b/pkg/web/sse_test.go index 790c7f27..c14efd4e 100644 --- a/pkg/web/sse_test.go +++ b/pkg/web/sse_test.go @@ -56,15 +56,15 @@ func TestHubBroadcastReliableSurvivesBackpressure(t *testing.T) { } } -// isTerminalChatEvent is the only test of the reliability classification: the +// isTerminalDomainEvent is the only test of the reliability classification: the // run-ending platform signal must qualify, or the stuck-cursor bug returns. // Agent lifecycle terminals are AOP events and covered by isReliableAOPEvent. -func TestIsTerminalChatEvent(t *testing.T) { - if !isTerminalChatEvent(ChatEventScanComplete) { - t.Errorf("%q should be terminal (reliable)", ChatEventScanComplete) +func TestIsTerminalDomainEvent(t *testing.T) { + if !isTerminalDomainEvent(DomainEventScanComplete) { + t.Errorf("%q should be terminal (reliable)", DomainEventScanComplete) } - for _, ty := range []string{ChatEventScanStarted, ChatEventScanProgress, ChatEventAgentJoined} { - if isTerminalChatEvent(ty) { + for _, ty := range []string{DomainEventScanStarted, DomainEventScanProgress, DomainEventAgentJoined} { + if isTerminalDomainEvent(ty) { t.Errorf("%q should not be terminal", ty) } } @@ -143,8 +143,8 @@ func TestScanCompletePersistsMarkerMetadata(t *testing.T) { // A completed scan must leave a durable marker so its inline card survives a // timeline rebuild (reload / session switch). The heavy Result is intentionally // not stored — only the scan_id, which the client re-hydrates via scan_ids. - svc.BroadcastChatEvent("sess-scan", ChatEvent{ - Type: ChatEventScanComplete, + svc.BroadcastDomainEvent("sess-scan", DomainEvent{ + Type: DomainEventScanComplete, ScanID: "scan-123", }) @@ -159,12 +159,12 @@ func TestScanCompletePersistsMarkerMetadata(t *testing.T) { if err := json.Unmarshal(msgs[0].Metadata, &metadata); err != nil { t.Fatalf("metadata json: %v", err) } - if metadata["event_type"] != ChatEventScanComplete || metadata["scan_id"] != "scan-123" { + if metadata["event_type"] != DomainEventScanComplete || metadata["scan_id"] != "scan-123" { t.Fatalf("scan marker metadata = %#v", metadata) } // A marker with no scan id is meaningless — it must not create a phantom row. - svc.BroadcastChatEvent("sess-scan-empty", ChatEvent{Type: ChatEventScanComplete}) + svc.BroadcastDomainEvent("sess-scan-empty", DomainEvent{Type: DomainEventScanComplete}) empty, _ := store.ListMessages(context.Background(), "sess-scan-empty", 100) if len(empty) != 0 { t.Fatalf("empty-scanID persisted messages = %d, want 0", len(empty)) diff --git a/pkg/web/types.go b/pkg/web/types.go index 8658aa9c..5643c3c5 100644 --- a/pkg/web/types.go +++ b/pkg/web/types.go @@ -194,11 +194,11 @@ type ChatMessage struct { } const ( - ChatEventScanStarted = "scan_started" - ChatEventScanProgress = "scan_progress" - ChatEventScanComplete = "scan_complete" - ChatEventAgentJoined = "agent_joined" - ChatEventSessionCleared = "session_cleared" + DomainEventScanStarted = "scan_started" + DomainEventScanProgress = "scan_progress" + DomainEventScanComplete = "scan_complete" + DomainEventAgentJoined = "agent_joined" + DomainEventSessionCleared = "session_cleared" ) // System message codes. A backend-generated system message carries a stable @@ -214,15 +214,11 @@ const ( SysAgentNotConnected = "agent_not_connected" ) -type ChatEvent struct { +type DomainEvent struct { Type string `json:"type"` SessionID string `json:"session_id"` - MessageID string `json:"message_id,omitempty"` - Role string `json:"role,omitempty"` AgentID string `json:"agent_id,omitempty"` AgentName string `json:"agent_name,omitempty"` - Turn int `json:"turn,omitempty"` - Content string `json:"content,omitempty"` ScanID string `json:"scan_id,omitempty"` Result *output.Result `json:"result,omitempty"` Data string `json:"data,omitempty"` diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index 6e7821c4..9f2136a4 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -662,19 +662,15 @@ export interface ChatMessage { created_at: string } -export type ChatEventType = +export type DomainEventType = | 'scan_started' | 'scan_progress' | 'scan_complete' | 'agent_joined' | 'session_cleared' -export interface ChatEvent { - type: ChatEventType +export interface DomainEvent { + type: DomainEventType session_id: string - message_id?: string - role?: ChatMessage['role'] agent_id?: string agent_name?: string - turn?: number - content?: string scan_id?: string result?: ScanResult data?: string @@ -784,14 +780,14 @@ export async function fetchScanReport(scanID: string, lang: string): Promise void, + onEvent: (event: DomainEvent) => void, onReconnect?: () => void, onAOP?: (event: AOPEvent) => void, onOpen?: () => void, ): () => void { - const eventTypes: ChatEventType[] = [ + const eventTypes: DomainEventType[] = [ 'scan_started', 'scan_progress', 'scan_complete', 'agent_joined', 'session_cleared', ] @@ -804,7 +800,7 @@ export function subscribeChatEvents( const parsed = JSON.parse(data) onEvent({ ...parsed, type }) } catch { - onEvent({ type, session_id: sessionID, data } as ChatEvent) + onEvent({ type, session_id: sessionID, data } as DomainEvent) } } } diff --git a/web/frontend/src/hooks/useChatSession.ts b/web/frontend/src/hooks/useChatSession.ts index db228f63..640dbba8 100644 --- a/web/frontend/src/hooks/useChatSession.ts +++ b/web/frontend/src/hooks/useChatSession.ts @@ -10,10 +10,10 @@ import { listChatMessages, listChatSessions, sendChatMessage, - subscribeChatEvents, + subscribeDomainEvents, getScan, } from '../api' -import type { AgentInfo, AOPEvent, ChatEvent, ChatMessage, ChatSession, ScanResult } from '../api' +import type { AgentInfo, AOPEvent, DomainEvent, ChatMessage, ChatSession, ScanResult } from '../api' import { isRootPath, parseRoute, @@ -242,7 +242,7 @@ export function useChatSession() { setPendingResponse(false) } - function handleChatEvent(event: ChatEvent) { + function handleDomainEvent(event: DomainEvent) { const now = Date.now() switch (event.type) { @@ -460,9 +460,9 @@ export function useChatSession() { } catch {} if (activation !== activationRef.current) return - unsubRef.current = subscribeChatEvents( + unsubRef.current = subscribeDomainEvents( id, - handleChatEvent, + handleDomainEvent, () => reconcileAfterReconnect(id), handleAOPEvent, () => { From 7e1469f421bd02982ae100391c76427f9b0fe7c2 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sat, 25 Jul 2026 12:47:34 +0800 Subject: [PATCH 3/9] refactor(runner): centralize runtime control state --- core/runner/runner.go | 6 +- core/runner/runtime_protocol.go | 103 ++++++++++++ core/runner/runtime_protocol_test.go | 64 +++++++ core/runner/runtime_session.go | 156 +++++++++++++++--- core/runner/runtime_session_isolation_test.go | 2 +- core/runner/stdio.go | 131 +-------------- core/runner/stdio_concurrency_test.go | 2 - pkg/web/service.go | 29 +++- pkg/webagent/agent.go | 121 +------------- pkg/webagent/agent_test.go | 22 --- pkg/webagent/connection.go | 44 +---- pkg/webagent/connection_lifecycle_test.go | 17 +- pkg/webproto/message.go | 1 + 13 files changed, 351 insertions(+), 347 deletions(-) create mode 100644 core/runner/runtime_protocol.go create mode 100644 core/runner/runtime_protocol_test.go diff --git a/core/runner/runner.go b/core/runner/runner.go index 8063d5ff..5d1dda03 100644 --- a/core/runner/runner.go +++ b/core/runner/runner.go @@ -45,10 +45,11 @@ type AgentRuntime struct { cancel context.CancelFunc mu sync.RWMutex sessions map[string]*sessionState - turnIDs map[string]struct{} + runs map[string]*Run requestSeq uint64 closeOnce sync.Once wg sync.WaitGroup + operations sync.WaitGroup ptyManager *tmuxpkg.Manager replMode REPLMode maxPending int @@ -88,7 +89,7 @@ func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.L ctx: runtimeCtx, cancel: runtimeCancel, sessions: make(map[string]*sessionState), - turnIDs: make(map[string]struct{}), + runs: make(map[string]*Run), } if rc != nil { rt.replMode = rc.REPLMode @@ -317,6 +318,7 @@ func (rt *AgentRuntime) Close() { _ = rt.CloseSession(context.Background(), id, SessionCloseRuntime) } rt.wg.Wait() + rt.operations.Wait() if rt.cleanup != nil { rt.cleanup() } diff --git a/core/runner/runtime_protocol.go b/core/runner/runtime_protocol.go new file mode 100644 index 00000000..97111dde --- /dev/null +++ b/core/runner/runtime_protocol.go @@ -0,0 +1,103 @@ +package runner + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/chainreactors/aiscan/pkg/webproto" +) + +// HandleProtocol handles the transport-neutral Agent Runtime control frames. +// The caller owns framing and I/O; AgentRuntime owns all Session and Run state. +func (rt *AgentRuntime) HandleProtocol(ctx context.Context, msg webproto.Message, send func(webproto.Message)) bool { + if rt == nil || send == nil { + return false + } + sendError := func(turnID, taskID string, err error) { + payload, _ := json.Marshal(webproto.ErrorPayload{Message: err.Error()}) + send(webproto.Message{Type: webproto.TypeError, TurnID: turnID, TaskID: taskID, Payload: payload}) + } + + switch msg.Type { + case webproto.TypeSessionOpen: + var payload webproto.SessionOpenPayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + sendError("", "", err) + return true + } + session, err := rt.EnsureSession(SessionOptions{ + ID: payload.SessionID, ParentSessionID: payload.ParentSessionID, ParentToolCallID: payload.ParentToolCallID, + }) + if err != nil { + sendError("", "", err) + return true + } + encoded, _ := json.Marshal(webproto.SessionLifecyclePayload{SessionID: session.ID()}) + send(webproto.Message{Type: webproto.TypeSessionOpened, Payload: encoded}) + return true + + case webproto.TypeSessionClose: + var payload webproto.SessionLifecyclePayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + sendError("", "", err) + return true + } + reason := SessionCloseReason(payload.Reason) + if err := rt.CloseSession(ctx, payload.SessionID, reason); err != nil { + sendError("", "", err) + return true + } + encoded, _ := json.Marshal(webproto.SessionLifecyclePayload{SessionID: payload.SessionID, Reason: string(reason)}) + send(webproto.Message{Type: webproto.TypeSessionClosed, Payload: encoded}) + return true + + case webproto.TypeRun: + if strings.TrimSpace(msg.TurnID) == "" { + sendError("", "", fmt.Errorf("run turn_id is required")) + return true + } + var payload webproto.RunPayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + sendError(msg.TurnID, "", err) + return true + } + _, err := rt.RunSession(ctx, payload.SessionID, RunInput{ + TurnID: msg.TurnID, Parts: payload.Parts, NoEcho: payload.NoEcho, MaxTurns: payload.MaxTurns, + EvalCriteria: payload.EvalCriteria, EvalMaxRounds: payload.EvalMaxRounds, Continue: payload.Continue, + }) + if err != nil { + sendError(msg.TurnID, "", err) + } + return true + + case webproto.TypeRunCancel: + if err := rt.CancelRun(msg.TurnID); err != nil { + sendError(msg.TurnID, "", err) + } + return true + + case webproto.TypeCommand: + var payload webproto.CommandPayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + sendError("", msg.TaskID, err) + return true + } + rt.operations.Add(1) + go func() { + defer rt.operations.Done() + result, err := rt.CommandSession(ctx, payload.SessionID, payload.Line) + if err != nil { + sendError("", msg.TaskID, err) + return + } + encoded, _ := json.Marshal(webproto.CommandResultPayload{ + SessionID: payload.SessionID, Parts: result.Parts, Metadata: result.Metadata, + }) + send(webproto.Message{Type: webproto.TypeCommandResult, TaskID: msg.TaskID, Payload: encoded}) + }() + return true + } + return false +} diff --git a/core/runner/runtime_protocol_test.go b/core/runner/runtime_protocol_test.go new file mode 100644 index 00000000..096af634 --- /dev/null +++ b/core/runner/runtime_protocol_test.go @@ -0,0 +1,64 @@ +package runner + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/chainreactors/aiscan/pkg/webproto" +) + +func TestProtocolErrorsKeepDistinctCorrelationIDs(t *testing.T) { + rt := newBareRuntime(t, nil, nil) + + var runError webproto.Message + if !rt.HandleProtocol(context.Background(), webproto.Message{ + Type: webproto.TypeRun, TurnID: "turn-1", Payload: json.RawMessage(`{`), + }, func(message webproto.Message) { runError = message }) { + t.Fatal("run frame was not handled") + } + if runError.Type != webproto.TypeError || runError.TurnID != "turn-1" || runError.TaskID != "" { + t.Fatalf("run error correlation = %+v", runError) + } + + var commandError webproto.Message + if !rt.HandleProtocol(context.Background(), webproto.Message{ + Type: webproto.TypeCommand, TaskID: "command-1", Payload: json.RawMessage(`{`), + }, func(message webproto.Message) { commandError = message }) { + t.Fatal("command frame was not handled") + } + if commandError.Type != webproto.TypeError || commandError.TaskID != "command-1" || commandError.TurnID != "" { + t.Fatalf("command error correlation = %+v", commandError) + } +} + +func TestProtocolRequiresTurnID(t *testing.T) { + rt := newBareRuntime(t, nil, nil) + var response webproto.Message + rt.HandleProtocol(context.Background(), webproto.Message{ + Type: webproto.TypeRun, Payload: webproto.MustJSON(webproto.RunPayload{SessionID: "session-1"}), + }, func(message webproto.Message) { response = message }) + var payload webproto.ErrorPayload + _ = json.Unmarshal(response.Payload, &payload) + if response.Type != webproto.TypeError || !strings.Contains(payload.Message, "turn_id is required") { + t.Fatalf("response = %+v payload=%+v", response, payload) + } +} + +func TestProtocolSessionOpenIsIdempotent(t *testing.T) { + rt := newBareRuntime(t, nil, nil) + request := webproto.Message{ + Type: webproto.TypeSessionOpen, + Payload: webproto.MustJSON(webproto.SessionOpenPayload{SessionID: "session-1"}), + } + for i := 0; i < 2; i++ { + var response webproto.Message + if !rt.HandleProtocol(context.Background(), request, func(message webproto.Message) { response = message }) { + t.Fatal("session.open was not handled") + } + if response.Type != webproto.TypeSessionOpened { + t.Fatalf("open %d response = %+v", i, response) + } + } +} diff --git a/core/runner/runtime_session.go b/core/runner/runtime_session.go index 1e29502d..ed0d95f2 100644 --- a/core/runner/runtime_session.go +++ b/core/runner/runtime_session.go @@ -71,6 +71,7 @@ type Session struct { type Run struct { turnID string done chan struct{} + cancel context.CancelFunc mu sync.Mutex result RunResult err error @@ -272,17 +273,19 @@ func (m *sessionMailbox) RegisterProducer(name string) *inboxpkg.ProducerHandle func (m *sessionMailbox) ActiveProducers() int { return m.base.ActiveProducers() } type sessionState struct { - runtime *AgentRuntime - id string - agentName string - agent *agent.Agent - inbox *sessionMailbox - scheduler *agent.LoopScheduler - commands *commandSession - ctx context.Context - cancel context.CancelFunc - ops chan *sessionOperation - done chan struct{} + runtime *AgentRuntime + id string + agentName string + parentSessionID string + parentToolCallID string + agent *agent.Agent + inbox *sessionMailbox + scheduler *agent.LoopScheduler + commands *commandSession + ctx context.Context + cancel context.CancelFunc + ops chan *sessionOperation + done chan struct{} mu sync.Mutex pending int @@ -338,7 +341,9 @@ func (rt *AgentRuntime) OpenSession(ctx context.Context, options SessionOptions) ag.LoadMessages(rt.resumeMessages) } state := &sessionState{ - runtime: rt, id: id, agentName: agentName, agent: ag, inbox: mailbox, + runtime: rt, id: id, agentName: agentName, + parentSessionID: options.ParentSessionID, parentToolCallID: options.ParentToolCallID, + agent: ag, inbox: mailbox, scheduler: scheduler, ctx: sessionCtx, cancel: cancel, ops: make(chan *sessionOperation, rt.pendingLimit()), done: make(chan struct{}), } @@ -363,6 +368,50 @@ func (rt *AgentRuntime) OpenSession(ctx context.Context, options SessionOptions) return public, nil } +// EnsureSession returns an existing Runtime-owned Session or opens it with the +// Runtime lifetime. It is idempotent so a transport reconnect can safely +// announce the same logical Session again. +func (rt *AgentRuntime) EnsureSession(options SessionOptions) (*Session, error) { + if rt == nil { + return nil, fmt.Errorf("agent runtime is not configured") + } + id := strings.TrimSpace(options.ID) + if id != "" { + rt.mu.RLock() + state := rt.sessions[id] + rt.mu.RUnlock() + if state != nil { + return ensuredSession(state, options) + } + } + session, err := rt.OpenSession(rt.ctx, options) + if err == nil || id == "" { + return session, err + } + // Concurrent reconnects may both observe the Session as absent. The strict + // OpenSession call admits one; the loser re-reads and validates that Session. + rt.mu.RLock() + state := rt.sessions[id] + rt.mu.RUnlock() + if state == nil { + return nil, err + } + return ensuredSession(state, options) +} + +func ensuredSession(state *sessionState, options SessionOptions) (*Session, error) { + if options.ParentSessionID != "" && options.ParentSessionID != state.parentSessionID { + return nil, fmt.Errorf("session %q parent_session_id conflicts with open session", state.id) + } + if options.ParentToolCallID != "" && options.ParentToolCallID != state.parentToolCallID { + return nil, fmt.Errorf("session %q parent_tool_call_id conflicts with open session", state.id) + } + if options.AgentName != "" && options.AgentName != state.agentName { + return nil, fmt.Errorf("session %q agent name conflicts with open session", state.id) + } + return &Session{state: state}, nil +} + func (rt *AgentRuntime) CloseSession(ctx context.Context, sessionID string, reason SessionCloseReason) error { if rt == nil { return fmt.Errorf("agent runtime is not configured") @@ -404,6 +453,58 @@ func (rt *AgentRuntime) Subscribe(fn func(aop.Event)) func() { return rt.bus.Subscribe(fn) } +func (rt *AgentRuntime) session(sessionID string) (*Session, error) { + if rt == nil { + return nil, fmt.Errorf("agent runtime is not configured") + } + rt.mu.RLock() + state := rt.sessions[strings.TrimSpace(sessionID)] + rt.mu.RUnlock() + if state == nil { + return nil, fmt.Errorf("session %q is not open", sessionID) + } + return &Session{state: state}, nil +} + +func (rt *AgentRuntime) RunSession(ctx context.Context, sessionID string, input RunInput) (*Run, error) { + session, err := rt.session(sessionID) + if err != nil { + return nil, err + } + return session.Run(ctx, input) +} + +func (rt *AgentRuntime) CommandSession(ctx context.Context, sessionID, line string) (CommandResult, error) { + session, err := rt.session(sessionID) + if err != nil { + return CommandResult{}, err + } + return session.Command(ctx, line) +} + +func (rt *AgentRuntime) CancelRun(turnID string) error { + if rt == nil { + return fmt.Errorf("agent runtime is not configured") + } + turnID = strings.TrimSpace(turnID) + rt.mu.RLock() + run := rt.runs[turnID] + rt.mu.RUnlock() + if run == nil { + return fmt.Errorf("turn %q is not active", turnID) + } + run.cancel() + return nil +} + +// WaitOperations waits for all Runs and asynchronous control operations that +// were admitted before the call. Transports use it to drain before shutdown. +func (rt *AgentRuntime) WaitOperations() { + if rt != nil { + rt.operations.Wait() + } +} + func (s *Session) Run(ctx context.Context, input RunInput) (*Run, error) { if s == nil || s.state == nil { return nil, fmt.Errorf("session is not configured") @@ -456,18 +557,24 @@ func (s *sessionState) startRun(ctx context.Context, input RunInput) (*Run, erro if turnID == "" { turnID = s.runtime.nextRuntimeID("turn") } + if ctx == nil { + ctx = context.Background() + } + runCtx, runCancel := context.WithCancel(ctx) + run := &Run{turnID: turnID, done: make(chan struct{}), cancel: runCancel} s.runtime.mu.Lock() - if _, exists := s.runtime.turnIDs[turnID]; exists { + if _, exists := s.runtime.runs[turnID]; exists { s.runtime.mu.Unlock() + runCancel() return nil, fmt.Errorf("turn %q already exists", turnID) } - s.runtime.turnIDs[turnID] = struct{}{} + s.runtime.runs[turnID] = run + s.runtime.operations.Add(1) s.runtime.mu.Unlock() - run := &Run{turnID: turnID, done: make(chan struct{})} emitter := &turnEmitter{sessionID: s.id, turnID: turnID, agentName: s.agentName, emitter: s.runtime.sessionEvents} op := &sessionOperation{ execute: func(runCtx context.Context) { - defer s.runtime.releaseTurnID(turnID) + defer s.runtime.releaseRun(run) s.inbox.setActive(true) emitter.start() result, runErr := s.executeRun(runCtx, turnID, input) @@ -489,7 +596,7 @@ func (s *sessionState) startRun(ctx context.Context, input RunInput) (*Run, erro run.finish(runResult, runErr) }, reject: func(err error) { - defer s.runtime.releaseTurnID(turnID) + defer s.runtime.releaseRun(run) result := RunResult{Stop: agent.StopReasonCanceled} if !errors.Is(err, context.Canceled) { result.Stop = agent.StopReasonError @@ -499,8 +606,8 @@ func (s *sessionState) startRun(ctx context.Context, input RunInput) (*Run, erro run.finish(result, err) }, } - if err := s.admit(ctx, op); err != nil { - s.runtime.releaseTurnID(turnID) + if err := s.admit(runCtx, op); err != nil { + s.runtime.releaseRun(run) return nil, err } return run, nil @@ -664,10 +771,17 @@ func (rt *AgentRuntime) nextRuntimeID(prefix string) string { return id } -func (rt *AgentRuntime) releaseTurnID(turnID string) { +func (rt *AgentRuntime) releaseRun(run *Run) { + if run == nil { + return + } + run.cancel() rt.mu.Lock() - delete(rt.turnIDs, turnID) + if rt.runs[run.turnID] == run { + delete(rt.runs, run.turnID) + } rt.mu.Unlock() + rt.operations.Done() } func (rt *AgentRuntime) providerSnapshot() (agent.Provider, string, telemetry.Logger) { diff --git a/core/runner/runtime_session_isolation_test.go b/core/runner/runtime_session_isolation_test.go index 7d82d66b..14b328d1 100644 --- a/core/runner/runtime_session_isolation_test.go +++ b/core/runner/runtime_session_isolation_test.go @@ -26,7 +26,7 @@ func newBareRuntime(t *testing.T, reg *commands.CommandRegistry, provider agent. kernelBus.Subscribe(events.emit) rt := &AgentRuntime{ app: &App{Commands: reg}, option: &cfg.Option{}, ctx: ctx, cancel: cancel, - sessions: make(map[string]*sessionState), turnIDs: make(map[string]struct{}), + sessions: make(map[string]*sessionState), runs: make(map[string]*Run), bus: publicBus, kernelBus: kernelBus, sessionEvents: events, config: agent.Config{Provider: provider, Tools: reg, Bus: kernelBus, Logger: telemetry.NopLogger()}, } diff --git a/core/runner/stdio.go b/core/runner/stdio.go index 4fc34aba..aa7c4516 100644 --- a/core/runner/stdio.go +++ b/core/runner/stdio.go @@ -49,17 +49,12 @@ type stdioHost struct { enc *json.Encoder encErr error - rt *AgentRuntime - mu sync.Mutex - sessions map[string]*Session - runs map[string]context.CancelFunc - wg sync.WaitGroup + rt *AgentRuntime } func newStdioHost(ctx context.Context, option *cfg.Option, logger telemetry.Logger, output io.Writer) *stdioHost { return &stdioHost{ ctx: ctx, option: option, logger: logger, enc: json.NewEncoder(output), - sessions: make(map[string]*Session), runs: make(map[string]context.CancelFunc), } } @@ -102,11 +97,6 @@ func (h *stdioHost) emitError(turnID string, err error) { _ = h.emit(webproto.Message{Type: webproto.TypeError, TurnID: turnID, Payload: payload}) } -func (h *stdioHost) emitTaskError(taskID string, err error) { - payload, _ := json.Marshal(webproto.ErrorPayload{Message: err.Error()}) - _ = h.emit(webproto.Message{Type: webproto.TypeError, TaskID: taskID, Payload: payload}) -} - func (h *stdioHost) err() error { h.encMu.Lock() defer h.encMu.Unlock() @@ -122,120 +112,13 @@ func (h *stdioHost) accept(line string) { h.emitError("", fmt.Errorf("decode frame: %w", err)) return } - switch message.Type { - case webproto.TypeSessionOpen: - var payload webproto.SessionOpenPayload - if err := json.Unmarshal(message.Payload, &payload); err != nil { - h.emitError("", err) - return - } - session, err := h.rt.OpenSession(h.ctx, SessionOptions{ - ID: payload.SessionID, ParentSessionID: payload.ParentSessionID, ParentToolCallID: payload.ParentToolCallID, - }) - if err != nil { - h.emitError("", err) - return - } - h.mu.Lock() - h.sessions[session.ID()] = session - h.mu.Unlock() - opened, _ := json.Marshal(webproto.SessionLifecyclePayload{SessionID: session.ID()}) - _ = h.emit(webproto.Message{Type: webproto.TypeSessionOpened, Payload: opened}) - - case webproto.TypeSessionClose: - var payload webproto.SessionLifecyclePayload - if err := json.Unmarshal(message.Payload, &payload); err != nil { - h.emitError("", err) - return - } - reason := SessionCloseReason(payload.Reason) - if err := h.rt.CloseSession(h.ctx, payload.SessionID, reason); err != nil { - h.emitError("", err) - return - } - h.mu.Lock() - delete(h.sessions, payload.SessionID) - h.mu.Unlock() - closed, _ := json.Marshal(webproto.SessionLifecyclePayload{SessionID: payload.SessionID, Reason: string(reason)}) - _ = h.emit(webproto.Message{Type: webproto.TypeSessionClosed, Payload: closed}) - - case webproto.TypeRun: - var payload webproto.RunPayload - if err := json.Unmarshal(message.Payload, &payload); err != nil { - h.emitError(message.TurnID, err) - return - } - h.mu.Lock() - session := h.sessions[payload.SessionID] - h.mu.Unlock() - if session == nil { - h.emitError(message.TurnID, fmt.Errorf("session %q is not open", payload.SessionID)) - return - } - runCtx, cancel := context.WithCancel(h.ctx) - run, err := session.Run(runCtx, RunInput{ - TurnID: message.TurnID, Parts: payload.Parts, NoEcho: payload.NoEcho, MaxTurns: payload.MaxTurns, - EvalCriteria: payload.EvalCriteria, EvalMaxRounds: payload.EvalMaxRounds, - }) - if err != nil { - cancel() - h.emitError(message.TurnID, err) - return - } - turnID := run.TurnID() - h.mu.Lock() - h.runs[turnID] = cancel - h.mu.Unlock() - h.wg.Add(1) - go func() { - defer h.wg.Done() - defer cancel() - _, _ = run.Wait() - h.mu.Lock() - delete(h.runs, turnID) - h.mu.Unlock() - }() - - case webproto.TypeRunCancel: - h.mu.Lock() - cancel := h.runs[message.TurnID] - h.mu.Unlock() - if cancel == nil { - h.emitError(message.TurnID, fmt.Errorf("turn %q is not active", message.TurnID)) - return - } - cancel() - - case webproto.TypeCommand: - var payload webproto.CommandPayload - if err := json.Unmarshal(message.Payload, &payload); err != nil { - h.emitTaskError(message.TaskID, err) - return - } - h.mu.Lock() - session := h.sessions[payload.SessionID] - h.mu.Unlock() - if session == nil { - h.emitTaskError(message.TaskID, fmt.Errorf("session %q is not open", payload.SessionID)) - return - } - h.wg.Add(1) - go func() { - defer h.wg.Done() - result, err := session.Command(h.ctx, payload.Line) - if err != nil { - h.emitTaskError(message.TaskID, err) - return - } - encoded, _ := json.Marshal(webproto.CommandResultPayload{ - SessionID: payload.SessionID, Parts: result.Parts, Metadata: result.Metadata, - }) - _ = h.emit(webproto.Message{Type: webproto.TypeCommandResult, TaskID: message.TaskID, Payload: encoded}) - }() - - default: + if h.rt == nil || !h.rt.HandleProtocol(h.ctx, message, func(response webproto.Message) { _ = h.emit(response) }) { h.emitError(message.TurnID, fmt.Errorf("unsupported frame type %q", message.Type)) } } -func (h *stdioHost) drain() { h.wg.Wait() } +func (h *stdioHost) drain() { + if h.rt != nil { + h.rt.WaitOperations() + } +} diff --git a/core/runner/stdio_concurrency_test.go b/core/runner/stdio_concurrency_test.go index df453250..d8729d6c 100644 --- a/core/runner/stdio_concurrency_test.go +++ b/core/runner/stdio_concurrency_test.go @@ -68,8 +68,6 @@ func newStdioTestSession(t *testing.T, h *stdioHost, output *bytes.Buffer, id st if h.rt == nil || h.rt.ctx == nil { initialized := newRuntimeStdioHost(t, output, prov) h.rt = initialized.rt - h.sessions = initialized.sessions - h.runs = initialized.runs } h.accept(openSessionLine(t, id)) } diff --git a/pkg/web/service.go b/pkg/web/service.go index c4d51f14..ef1fb211 100644 --- a/pkg/web/service.go +++ b/pkg/web/service.go @@ -1330,8 +1330,17 @@ func (s *Service) dispatchUserMessage(sessionID string, msg *ChatMessage, opts w case "exit", "quit": s.closeRemoteSession(sessionID) return - case "continue", "followup": - // These are Runs: the adapter normalizes their prompt semantics. + case "continue": + s.handleAgentRun(sessionID, webproto.RunPayload{ + SessionID: sessionID, Continue: true, NoEcho: true, + MaxTurns: opts.PersistMaxTurns, EvalCriteria: opts.EvalCriteria, EvalMaxRounds: opts.EvalMaxRounds, + }) + return + case "followup": + followup := *msg + followup.Content = strings.TrimSpace(args) + s.handleChatMessage(sessionID, &followup, opts) + return default: if !strings.HasPrefix(content, "/skill:") { s.handleAgentCommand(sessionID, content) @@ -1521,6 +1530,16 @@ func (s *Service) sessionAgent(sessionID string) *remoteAgent { } func (s *Service) handleChatMessage(sessionID string, msg *ChatMessage, opts webproto.GoalExt) { + run := webproto.RunPayload{ + SessionID: sessionID, + Parts: []aop.MessagePart{{Type: aop.PartText, Text: strings.TrimSpace(msg.Content)}}, + NoEcho: true, MaxTurns: opts.PersistMaxTurns, + EvalCriteria: opts.EvalCriteria, EvalMaxRounds: opts.EvalMaxRounds, + } + s.handleAgentRun(sessionID, run) +} + +func (s *Service) handleAgentRun(sessionID string, run webproto.RunPayload) { agent := s.sessionAgent(sessionID) if agent == nil { s.broadcastSystemMessage(sessionID, SysAgentNotConnected, @@ -1537,12 +1556,6 @@ func (s *Service) handleChatMessage(sessionID string, msg *ChatMessage, opts web AgentName: agent.name, }) - run := webproto.RunPayload{ - SessionID: sessionID, - Parts: []aop.MessagePart{{Type: aop.PartText, Text: strings.TrimSpace(msg.Content)}}, - NoEcho: true, MaxTurns: opts.PersistMaxTurns, - EvalCriteria: opts.EvalCriteria, EvalMaxRounds: opts.EvalMaxRounds, - } resultCh, err := s.agents.DispatchRun(agent.id, taskID, run) if err != nil { s.finishSessionTask(taskID) diff --git a/pkg/webagent/agent.go b/pkg/webagent/agent.go index 18d9f4cd..b3923e73 100644 --- a/pkg/webagent/agent.go +++ b/pkg/webagent/agent.go @@ -8,7 +8,6 @@ import ( "os" "path/filepath" "strings" - "sync" cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/runner" @@ -60,7 +59,6 @@ func RunWebSocket(ctx context.Context, option *cfg.Option, logger telemetry.Logg chatHandler := &chatAgentHandler{ rt: rt, serverURL: option.WebURL, - sessions: make(map[string]*runner.Session), app: application, option: option, logger: logger, @@ -107,12 +105,11 @@ func RunWebSocket(ctx context.Context, option *cfg.Option, logger telemetry.Logg return nil } - startup, err := rt.OpenSession(ctx, runner.SessionOptions{ID: "startup"}) + _, err = rt.EnsureSession(runner.SessionOptions{ID: "startup"}) if err != nil { return err } - chatHandler.sessions["startup"] = startup - run, err := startup.Run(ctx, runner.RunInput{TurnID: "startup", Parts: []aop.MessagePart{{Type: aop.PartText, Text: task}}}) + run, err := rt.RunSession(ctx, "startup", runner.RunInput{TurnID: "startup", Parts: []aop.MessagePart{{Type: aop.PartText, Text: task}}}) if err == nil { _, err = run.Wait() } @@ -129,123 +126,13 @@ func RunWebSocket(ctx context.Context, option *cfg.Option, logger telemetry.Logg type chatAgentHandler struct { rt *runner.AgentRuntime serverURL string - mu sync.Mutex - sessions map[string]*runner.Session app *runner.App option *cfg.Option logger telemetry.Logger } -func (h *chatAgentHandler) HandleSessionOpen(ctx context.Context, msg webproto.Message, send func(webproto.Message)) { - var payload webproto.SessionOpenPayload - if err := json.Unmarshal(msg.Payload, &payload); err != nil { - sendProtocolError(send, "", "", err) - return - } - session, err := h.rt.OpenSession(ctx, runner.SessionOptions{ - ID: payload.SessionID, ParentSessionID: payload.ParentSessionID, ParentToolCallID: payload.ParentToolCallID, - }) - if err != nil { - sendProtocolError(send, "", "", err) - return - } - sessionID := session.ID() - h.mu.Lock() - h.sessions[sessionID] = session - h.mu.Unlock() - encoded, _ := json.Marshal(webproto.SessionLifecyclePayload{SessionID: sessionID}) - send(webproto.Message{Type: webproto.TypeSessionOpened, Payload: encoded}) -} - -func (h *chatAgentHandler) HandleSessionClose(ctx context.Context, msg webproto.Message, send func(webproto.Message)) { - var payload webproto.SessionLifecyclePayload - if err := json.Unmarshal(msg.Payload, &payload); err != nil { - sendProtocolError(send, "", "", err) - return - } - reason := runner.SessionCloseReason(payload.Reason) - if err := h.rt.CloseSession(ctx, payload.SessionID, reason); err != nil { - sendProtocolError(send, "", "", err) - return - } - h.mu.Lock() - delete(h.sessions, payload.SessionID) - h.mu.Unlock() - encoded, _ := json.Marshal(webproto.SessionLifecyclePayload{SessionID: payload.SessionID, Reason: payload.Reason}) - send(webproto.Message{Type: webproto.TypeSessionClosed, Payload: encoded}) -} - -func (h *chatAgentHandler) HandleRun(ctx context.Context, msg webproto.Message, send func(webproto.Message)) func() { - var payload webproto.RunPayload - if err := json.Unmarshal(msg.Payload, &payload); err != nil { - return func() { sendProtocolError(send, msg.TurnID, "", err) } - } - h.mu.Lock() - session := h.sessions[payload.SessionID] - h.mu.Unlock() - if session == nil { - return func() { - sendProtocolError(send, msg.TurnID, "", fmt.Errorf("session %q is not open", payload.SessionID)) - } - } - input := runner.RunInput{ - TurnID: msg.TurnID, Parts: payload.Parts, NoEcho: payload.NoEcho, MaxTurns: payload.MaxTurns, - EvalCriteria: payload.EvalCriteria, EvalMaxRounds: payload.EvalMaxRounds, - } - prompt := strings.TrimSpace(partsText(payload.Parts)) - if prompt == "/continue" { - input.Continue = true - input.Parts = nil - } else if strings.HasPrefix(prompt, "/followup ") { - input.Parts = []aop.MessagePart{{Type: aop.PartText, Text: strings.TrimSpace(strings.TrimPrefix(prompt, "/followup "))}} - } - run, err := session.Run(ctx, input) - if err != nil { - return func() { sendProtocolError(send, msg.TurnID, "", err) } - } - return func() { _, _ = run.Wait() } -} - -func (h *chatAgentHandler) HandleCommand(ctx context.Context, msg webproto.Message, send func(webproto.Message)) { - var payload webproto.CommandPayload - if err := json.Unmarshal(msg.Payload, &payload); err != nil { - sendProtocolError(send, "", msg.TaskID, err) - return - } - h.mu.Lock() - session := h.sessions[payload.SessionID] - h.mu.Unlock() - if session == nil { - sendProtocolError(send, "", msg.TaskID, fmt.Errorf("session %q is not open", payload.SessionID)) - return - } - result, err := session.Command(ctx, payload.Line) - if err != nil { - sendProtocolError(send, "", msg.TaskID, err) - return - } - for i := range result.Parts { - if result.Parts[i].Type == aop.PartText { - result.Parts[i].Text = fenceTerminalOutput(result.Parts[i].Text) - } - } - encoded, _ := json.Marshal(webproto.CommandResultPayload{SessionID: payload.SessionID, Parts: result.Parts, Metadata: result.Metadata}) - send(webproto.Message{Type: webproto.TypeCommandResult, TaskID: msg.TaskID, Payload: encoded}) -} - -func sendProtocolError(send func(webproto.Message), turnID, taskID string, err error) { - payload, _ := json.Marshal(webproto.ErrorPayload{Message: err.Error()}) - send(webproto.Message{Type: webproto.TypeError, TurnID: turnID, TaskID: taskID, Payload: payload}) -} - -func partsText(parts []aop.MessagePart) string { - var values []string - for _, part := range parts { - if part.Type == aop.PartText && part.Text != "" { - values = append(values, part.Text) - } - } - return strings.Join(values, "\n") +func (h *chatAgentHandler) HandleProtocol(ctx context.Context, msg webproto.Message, send func(webproto.Message)) bool { + return h.rt != nil && h.rt.HandleProtocol(ctx, msg, send) } func (h *chatAgentHandler) HandleUpload(msg webproto.Message, send func(webproto.Message)) { diff --git a/pkg/webagent/agent_test.go b/pkg/webagent/agent_test.go index 1e25ce05..160510da 100644 --- a/pkg/webagent/agent_test.go +++ b/pkg/webagent/agent_test.go @@ -16,7 +16,6 @@ import ( cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/core/runner" "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/webproto" @@ -41,27 +40,6 @@ func TestWebNodeRefUsesWebIdentity(t *testing.T) { } } -func TestRunAndCommandErrorsKeepDistinctCorrelationIDs(t *testing.T) { - h := &chatAgentHandler{sessions: make(map[string]*runner.Session)} - - var runError webproto.Message - wait := h.HandleRun(context.Background(), webproto.Message{ - Type: webproto.TypeRun, TurnID: "turn-1", Payload: json.RawMessage(`{`), - }, func(message webproto.Message) { runError = message }) - wait() - if runError.Type != webproto.TypeError || runError.TurnID != "turn-1" || runError.TaskID != "" { - t.Fatalf("run error correlation = %+v", runError) - } - - var commandError webproto.Message - h.HandleCommand(context.Background(), webproto.Message{ - Type: webproto.TypeCommand, TaskID: "command-1", Payload: json.RawMessage(`{`), - }, func(message webproto.Message) { commandError = message }) - if commandError.Type != webproto.TypeError || commandError.TaskID != "command-1" || commandError.TurnID != "" { - t.Fatalf("command error correlation = %+v", commandError) - } -} - func connectForTest(ctx context.Context, serverURL, name string, reg *commands.CommandRegistry, bus *eventbus.Bus[aop.Event]) error { if _, ok := reg.GetTool("bash"); !ok { bash := commands.NewBashTool(".", 5) diff --git a/pkg/webagent/connection.go b/pkg/webagent/connection.go index aa184c8f..4ce55c9e 100644 --- a/pkg/webagent/connection.go +++ b/pkg/webagent/connection.go @@ -57,10 +57,7 @@ type connectionConfig struct { // Implementations live in webagent or other packages that have access to the // agent runtime and provider. type chatHandler interface { - HandleSessionOpen(ctx context.Context, msg webproto.Message, send func(webproto.Message)) - HandleSessionClose(ctx context.Context, msg webproto.Message, send func(webproto.Message)) - HandleRun(ctx context.Context, msg webproto.Message, send func(webproto.Message)) func() - HandleCommand(ctx context.Context, msg webproto.Message, send func(webproto.Message)) + HandleProtocol(ctx context.Context, msg webproto.Message, send func(webproto.Message)) bool // HandleUpload processes a file upload message. HandleUpload(msg webproto.Message, send func(webproto.Message)) @@ -224,7 +221,6 @@ func connectOnce(ctx context.Context, cc connectionConfig, logger telemetry.Logg var mu sync.Mutex execTasks := make(map[string]context.CancelFunc) // active tool.call tasks - turnCancels := make(map[string]context.CancelFunc) // Tool telemetry: scanner tool.data and normalized tool.sco events ride the // same connection, correlated to the calling task by call ID. @@ -299,35 +295,11 @@ func connectOnce(ctx context.Context, cc connectionConfig, logger telemetry.Logg } switch msg.Type { - case webproto.TypeSessionOpen: + case webproto.TypeSessionOpen, webproto.TypeSessionClose, webproto.TypeRun, webproto.TypeRunCancel: if cc.Chat != nil { - cc.Chat.HandleSessionOpen(connectionCtx, msg, send) + cc.Chat.HandleProtocol(connectionCtx, msg, send) } - case webproto.TypeSessionClose: - if cc.Chat != nil { - cc.Chat.HandleSessionClose(connectionCtx, msg, send) - } - - case webproto.TypeRun: - if cc.Chat == nil || msg.TurnID == "" { - continue - } - runCtx, runCancel := context.WithCancel(connectionCtx) - mu.Lock() - turnCancels[msg.TurnID] = runCancel - mu.Unlock() - wait := cc.Chat.HandleRun(runCtx, msg, send) - go func(turnID string) { - defer runCancel() - defer func() { - mu.Lock() - delete(turnCancels, turnID) - mu.Unlock() - }() - wait() - }(msg.TurnID) - case webproto.TypeCommand: var command webproto.CommandPayload if json.Unmarshal(msg.Payload, &command) != nil { @@ -348,7 +320,7 @@ func connectOnce(ctx context.Context, cc connectionConfig, logger telemetry.Logg HandleToolCommand(taskCtx, m, call, cc.Registry, cc.DataBus, send) }(msg, *command.ToolCall) } else if cc.Chat != nil { - go cc.Chat.HandleCommand(connectionCtx, msg, send) + cc.Chat.HandleProtocol(connectionCtx, msg, send) } case "upload": @@ -392,14 +364,6 @@ func connectOnce(ctx context.Context, cc connectionConfig, logger telemetry.Logg go cc.Chat.HandleConfigReload(cc.ServerURL, send) } - case webproto.TypeRunCancel: - mu.Lock() - cancel := turnCancels[msg.TurnID] - mu.Unlock() - if cancel != nil { - cancel() - } - case "cancel": mu.Lock() if cancel, ok := execTasks[msg.TaskID]; ok { diff --git a/pkg/webagent/connection_lifecycle_test.go b/pkg/webagent/connection_lifecycle_test.go index f14d5b4b..2d908215 100644 --- a/pkg/webagent/connection_lifecycle_test.go +++ b/pkg/webagent/connection_lifecycle_test.go @@ -22,19 +22,16 @@ type disconnectChatHandler struct { once sync.Once } -func (h *disconnectChatHandler) HandleRun(ctx context.Context, _ webproto.Message, _ func(webproto.Message)) func() { +func (h *disconnectChatHandler) HandleProtocol(ctx context.Context, msg webproto.Message, _ func(webproto.Message)) bool { + if msg.Type != webproto.TypeRun { + return false + } h.once.Do(func() { close(h.started) }) - return func() { + go func() { <-ctx.Done() close(h.canceled) - } -} - -func (*disconnectChatHandler) HandleSessionOpen(context.Context, webproto.Message, func(webproto.Message)) { -} -func (*disconnectChatHandler) HandleSessionClose(context.Context, webproto.Message, func(webproto.Message)) { -} -func (*disconnectChatHandler) HandleCommand(context.Context, webproto.Message, func(webproto.Message)) { + }() + return true } func (*disconnectChatHandler) HandleUpload(webproto.Message, func(webproto.Message)) {} func (*disconnectChatHandler) HandleConfigReload(string, func(webproto.Message)) {} diff --git a/pkg/webproto/message.go b/pkg/webproto/message.go index ee7688e6..1669a00a 100644 --- a/pkg/webproto/message.go +++ b/pkg/webproto/message.go @@ -46,6 +46,7 @@ type SessionLifecyclePayload struct { type RunPayload struct { SessionID string `json:"session_id"` Parts []aop.MessagePart `json:"parts"` + Continue bool `json:"continue,omitempty"` NoEcho bool `json:"no_echo,omitempty"` MaxTurns int `json:"max_turns,omitempty"` EvalCriteria string `json:"eval_criteria,omitempty"` From 8658d10d37e9b0857a4c8cc7700d22decf8cec28 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sat, 25 Jul 2026 12:56:17 +0800 Subject: [PATCH 4/9] refactor(protocol): route direct tool execution through AOP --- pkg/agent/loop.go | 10 +--- pkg/aop/tool_result.go | 60 +++++++++++++++++++ pkg/aop/tool_result_test.go | 34 +++++++++++ pkg/web/agents.go | 106 +++++++++++++++------------------- pkg/web/agents_test.go | 26 +++++---- pkg/webagent/agent_test.go | 15 +++-- pkg/webagent/aop_tool.go | 70 +++++++++++----------- pkg/webagent/aop_tool_test.go | 62 +++++++++++++++----- pkg/webagent/connection.go | 41 +++++++------ pkg/webagent/toolnode_test.go | 30 ++++++---- pkg/webproto/message.go | 5 +- 11 files changed, 290 insertions(+), 169 deletions(-) create mode 100644 pkg/aop/tool_result.go create mode 100644 pkg/aop/tool_result_test.go diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 0a1f1332..744420e9 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -535,14 +535,8 @@ func runToolCall(ctx context.Context, cfg Config, assistantMsg ChatMessage, tc T // eventContent returns the AOP tool.result payload: a plain string, or the // {content, images} variant when the tool returned images. func (e toolExecution) eventContent() any { - if e.fullResult != nil && e.fullResult.HasImages() { - trc := aop.ToolResultContent{Content: e.eventResultText()} - for _, block := range e.fullResult.Content { - if block.Type == "image" { - trc.Images = append(trc.Images, aop.ImageSource{Base64: block.Base64Data, MediaType: block.MimeType}) - } - } - return trc + if e.fullResult != nil { + return aop.ToolResultContentFromResult(*e.fullResult, e.eventResultText()) } return e.eventResultText() } diff --git a/pkg/aop/tool_result.go b/pkg/aop/tool_result.go new file mode 100644 index 00000000..93ad9d0a --- /dev/null +++ b/pkg/aop/tool_result.go @@ -0,0 +1,60 @@ +package aop + +import ( + "fmt" + "time" + + "github.com/chainreactors/aiscan/core/tool" +) + +// ToolResultContentFromResult converts the canonical tool Result blocks to the +// AOP content variant without flattening images or changing the supplied text. +func ToolResultContentFromResult(result tool.Result, text string) any { + if !result.HasImages() { + return text + } + content := ToolResultContent{Content: text} + for _, block := range result.Content { + if block.Type == "image" { + content.Images = append(content.Images, ImageSource{Base64: block.Base64Data, MediaType: block.MimeType}) + } + } + return content +} + +// ToolResultDataFromResult is the single conversion used by Agent-internal and +// direct remote tool execution. +func ToolResultDataFromResult(call ToolCallData, result tool.Result, execErr error, duration time.Duration) ToolResultData { + text := result.Text() + if execErr != nil { + text = execErr.Error() + } + return ToolResultData{ + ToolCallID: call.ToolCallID, + ToolName: call.ToolName, + Content: ToolResultContentFromResult(result, text), + Details: result.Details, + Terminate: result.Terminate, + IsError: execErr != nil || result.IsError, + DurationMs: int(duration.Milliseconds()), + } +} + +// ToolResultText reads both in-memory and JSON-decoded structured content. +func ToolResultText(content any) string { + switch value := content.(type) { + case string: + return value + case ToolResultContent: + return value.Content + case *ToolResultContent: + if value != nil { + return value.Content + } + case map[string]any: + if text, ok := value["content"].(string); ok { + return text + } + } + return fmt.Sprint(content) +} diff --git a/pkg/aop/tool_result_test.go b/pkg/aop/tool_result_test.go new file mode 100644 index 00000000..3f213dd0 --- /dev/null +++ b/pkg/aop/tool_result_test.go @@ -0,0 +1,34 @@ +package aop + +import ( + "errors" + "testing" + "time" + + "github.com/chainreactors/aiscan/core/tool" +) + +func TestToolResultDataFromResultPreservesStructuredContent(t *testing.T) { + result := tool.Result{ + Content: []tool.ContentBlock{ + tool.TextBlock("done"), + tool.ImageBlock("image/png", "aGVsbG8="), + }, + Details: map[string]any{"ports": 3}, Terminate: true, + } + data := ToolResultDataFromResult(ToolCallData{ToolCallID: "call-1", ToolName: "scan"}, result, nil, 12*time.Millisecond) + if data.ToolCallID != "call-1" || data.ToolName != "scan" || data.DurationMs != 12 || !data.Terminate || data.IsError { + t.Fatalf("data = %+v", data) + } + content, ok := data.Content.(ToolResultContent) + if !ok || content.Content != "done" || len(content.Images) != 1 || content.Images[0].MediaType != "image/png" { + t.Fatalf("content = %#v", data.Content) + } +} + +func TestToolResultDataFromResultUsesExecutionError(t *testing.T) { + data := ToolResultDataFromResult(ToolCallData{ToolCallID: "call-1"}, tool.TextResult("partial"), errors.New("failed"), 0) + if !data.IsError || ToolResultText(data.Content) != "failed" { + t.Fatalf("data = %+v", data) + } +} diff --git a/pkg/web/agents.go b/pkg/web/agents.go index 3090d552..b1c0bfcc 100644 --- a/pkg/web/agents.go +++ b/pkg/web/agents.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "strings" "sync" "sync/atomic" "time" @@ -265,31 +264,51 @@ func (p *AgentPool) PickChat() *remoteAgent { return fallback } -// DispatchToolCall sends a structured Command to a tool-capable node and -// returns a channel for the result. taskID correlates this non-Run RPC and its -// progress telemetry. +// DispatchToolCall sends a canonical AOP tool.call to a tool-capable node. +// The task completes only on the matching AOP tool.result. func (p *AgentPool) DispatchToolCall(agentID, taskID string, call aop.ToolCallData) (<-chan taskResult, error) { - payload, err := json.Marshal(webproto.CommandPayload{SessionID: taskID, ToolCall: &call}) - if err != nil { - return nil, fmt.Errorf("marshal command: %w", err) + a := p.get(agentID) + if a == nil { + return nil, fmt.Errorf("agent %s not connected", agentID) } - if a := p.get(agentID); a != nil { - a.mu.Lock() - if a.toolCalls == nil { - a.toolCalls = map[string]struct{}{} + call.ToolCallID = taskID + sessionID := taskID + if p.sessions != nil { + if sid, ok := p.sessions.TaskSession(taskID); ok { + sessionID = sid } - a.toolCalls[taskID] = struct{}{} - a.mu.Unlock() } - ch, err := p.dispatchMessage(agentID, taskID, webproto.Message{Type: webproto.TypeCommand, TaskID: taskID, Payload: payload}) + agentName := a.name + if agentName == "" { + agentName = a.id + } + data, err := json.Marshal(call) if err != nil { - if a := p.get(agentID); a != nil { - a.mu.Lock() - delete(a.toolCalls, taskID) - a.mu.Unlock() - } + return nil, fmt.Errorf("marshal tool.call: %w", err) + } + event := aop.Event{ + Type: aop.TypeToolCall, TS: time.Now().UTC().Format(time.RFC3339Nano), + SessionID: sessionID, TurnID: taskID, Agent: agentName, Data: data, + } + payload, _ := json.Marshal(event) + a.mu.Lock() + if a.toolCalls == nil { + a.toolCalls = map[string]struct{}{} + } + a.toolCalls[taskID] = struct{}{} + a.mu.Unlock() + ch, err := p.dispatchMessage(agentID, taskID, webproto.Message{ + Type: webproto.TypeAOP, TaskID: taskID, TurnID: taskID, Payload: payload, + }) + if err != nil { + a.mu.Lock() + delete(a.toolCalls, taskID) + a.mu.Unlock() return nil, err } + if p.sessions != nil && sessionID != taskID { + p.sessions.BroadcastAOPEvent(sessionID, event) + } return ch, nil } @@ -878,25 +897,10 @@ func (p *AgentPool) handleAgentMessage(a *remoteAgent, msg webproto.Message) { if ok && ch != nil { result := taskResult{Result: msg.Payload} if isToolCall { - var command webproto.CommandResultPayload - if err := json.Unmarshal(msg.Payload, &command); err != nil { - result.Err = "decode command.result: " + err.Error() - } else { - result.Output = commandPartsText(command.Parts) - if isError, _ := command.Metadata["is_error"].(bool); isError { - result.Err, result.Output = result.Output, "" - } - if details := command.Metadata["details"]; details != nil { - result.Result, _ = json.Marshal(details) - } - } + result = taskResult{Err: "direct tool task returned command.result; expected AOP tool.result"} } ch <- result close(ch) - if isToolCall { - p.recordScanResultStats(a, result.Result) - p.persistResultRecords(a, msg.TaskID, result.Result) - } } // complete/error are the terminal envelopes of the file RPCs only; agent @@ -1000,7 +1004,11 @@ func (p *AgentPool) forwardAOPEvent(a *remoteAgent, msg webproto.Message) { // Session-topic broadcast is optional (scans dispatched outside chat have // no chat session); task convergence below is not. if p.sessions != nil { - if sid, ok := p.sessions.TaskSession(msg.TurnID); ok { + correlationID := msg.TurnID + if msg.TaskID != "" { + correlationID = msg.TaskID + } + if sid, ok := p.sessions.TaskSession(correlationID); ok { p.sessions.BroadcastAOPEvent(sid, aopEv) } else if aopEv.SessionID != "" { p.sessions.BroadcastAOPEvent(aopEv.SessionID, aopEv) @@ -1045,7 +1053,7 @@ func (p *AgentPool) convergeTaskOnToolResult(a *remoteAgent, taskID string, ev a close(ch) return } - res := taskResult{Output: toolResultText(d.Content), Turn: turn} + res := taskResult{Output: aop.ToolResultText(d.Content), Turn: turn} if d.IsError { res.Err = res.Output res.Output = "" @@ -1061,30 +1069,6 @@ func (p *AgentPool) convergeTaskOnToolResult(a *remoteAgent, taskID string, ev a p.persistResultRecords(a, taskID, details) } -// toolResultText flattens tool.result content: a plain string, or the text of -// a structured ToolResultContent (text plus images). -func toolResultText(content any) string { - switch c := content.(type) { - case string: - return c - case map[string]any: - if text, ok := c["content"].(string); ok { - return text - } - } - return "" -} - -func commandPartsText(parts []aop.MessagePart) string { - var values []string - for _, part := range parts { - if part.Type == aop.PartText && part.Text != "" { - values = append(values, part.Text) - } - } - return strings.Join(values, "\n") -} - // convergeTaskOnSessionEnd closes a chat task when the ROOT agent session // ends: this terminal event drives task cleanup; child (derived sub-agent) // session ends and mid-run AOP error events are not terminal. Idempotent — diff --git a/pkg/web/agents_test.go b/pkg/web/agents_test.go index ce9128d3..6da788dc 100644 --- a/pkg/web/agents_test.go +++ b/pkg/web/agents_test.go @@ -235,17 +235,20 @@ func TestWSDispatchAndComplete(t *testing.T) { var cmd WSMessage conn.ReadJSON(&cmd) - if cmd.Type != webproto.TypeCommand { + if cmd.Type != webproto.TypeAOP || cmd.TaskID != "task-1" { t.Fatalf("unexpected: %+v", cmd) } - var command webproto.CommandPayload - if err := json.Unmarshal(cmd.Payload, &command); err != nil { + var callEvent aop.Event + if err := json.Unmarshal(cmd.Payload, &callEvent); err != nil { t.Fatal(err) } - if command.ToolCall == nil || command.SessionID != "task-1" { - t.Fatalf("unexpected command: %+v", command) + if callEvent.Type != aop.TypeToolCall || callEvent.TurnID != "task-1" { + t.Fatalf("unexpected tool.call event: %+v", callEvent) + } + call, err := aop.DecodeData[aop.ToolCallData](callEvent) + if err != nil { + t.Fatal(err) } - call := *command.ToolCall args, _ := call.Args.(map[string]any) if call.ToolName != "bash" || args["command"] != "scan -i 1.2.3.4" { t.Fatalf("unexpected tool.call data: %+v", call) @@ -262,11 +265,14 @@ func TestWSDispatchAndComplete(t *testing.T) { t.Fatal("timeout") } - resultPayload, _ := json.Marshal(webproto.CommandResultPayload{ - Parts: []aop.MessagePart{{Type: aop.PartText, Text: "done"}}, - Metadata: map[string]any{"tool_call_id": "task-1", "tool_name": "bash", "details": map[string]int{"ports": 3}}, + resultData, _ := json.Marshal(aop.ToolResultData{ + ToolCallID: "task-1", ToolName: "bash", Content: "done", Details: map[string]int{"ports": 3}, }) - conn.WriteJSON(WSMessage{Type: webproto.TypeCommandResult, TaskID: "task-1", Payload: resultPayload}) + resultEvent := callEvent + resultEvent.Type = aop.TypeToolResult + resultEvent.TS = time.Now().UTC().Format(time.RFC3339Nano) + resultEvent.Data = resultData + conn.WriteJSON(WSMessage{Type: webproto.TypeAOP, TaskID: "task-1", TurnID: "task-1", Payload: webproto.MustJSON(resultEvent)}) select { case res := <-resultCh: if res.Err != "" || res.Output != "done" { diff --git a/pkg/webagent/agent_test.go b/pkg/webagent/agent_test.go index 160510da..52a85fcd 100644 --- a/pkg/webagent/agent_test.go +++ b/pkg/webagent/agent_test.go @@ -107,12 +107,12 @@ func TestRunConnectionScopesTelemetryToActiveTask(t *testing.T) { registeredOnce.Do(func() { close(registered) }) call := aop.ToolCallData{ - ToolCallID: "call-1", + ToolCallID: "task-1", ToolName: "bash", Args: map[string]any{"command": `echo "hello world"`}, } - payload, _ := json.Marshal(webproto.CommandPayload{SessionID: "task-1", ToolCall: &call}) - if err := conn.WriteJSON(webproto.Message{Type: webproto.TypeCommand, TaskID: "task-1", Payload: payload}); err != nil { + payload, _ := json.Marshal(toolEvent(t, call)) + if err := conn.WriteJSON(webproto.Message{Type: webproto.TypeAOP, TaskID: "task-1", TurnID: "task-1", Payload: payload}); err != nil { t.Errorf("tool.call write: %v", err) return } @@ -122,7 +122,7 @@ func TestRunConnectionScopesTelemetryToActiveTask(t *testing.T) { return } messages <- msg - if msg.Type == webproto.TypeCommandResult { + if msg.Type == webproto.TypeAOP { return } } @@ -165,8 +165,11 @@ func TestRunConnectionScopesTelemetryToActiveTask(t *testing.T) { seenOutput = true } } - case webproto.TypeCommandResult: - seenResult = true + case webproto.TypeAOP: + var event aop.Event + if json.Unmarshal(msg.Payload, &event) == nil && event.Type == aop.TypeToolResult { + seenResult = true + } } case <-deadline: t.Fatal("timeout waiting for web agent messages") diff --git a/pkg/webagent/aop_tool.go b/pkg/webagent/aop_tool.go index a54c37b7..75511a33 100644 --- a/pkg/webagent/aop_tool.go +++ b/pkg/webagent/aop_tool.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "strings" "time" @@ -32,48 +33,49 @@ type foregroundTool interface { RunForegroundTool(context.Context, string, commands.BashExecOptions) (tool.Result, error) } -// HandleToolCommand executes one structured direct Command and returns a -// command.result frame. It does not create a Session or Turn. -func HandleToolCommand(ctx context.Context, msg webproto.Message, call aop.ToolCallData, executor aopToolExecutor, dataBus *eventbus.Bus[output.ToolDataEvent], send func(webproto.Message)) { +// HandleToolCallEvent executes one direct AOP tool.call and returns its +// terminal tool.result through the same AOP envelope. +func HandleToolCallEvent(ctx context.Context, msg webproto.Message, event aop.Event, executor aopToolExecutor, dataBus *eventbus.Bus[output.ToolDataEvent], send func(webproto.Message)) { + sendError := func(err error) { + payload, _ := json.Marshal(webproto.ErrorPayload{Message: err.Error()}) + send(webproto.Message{Type: webproto.TypeError, TaskID: msg.TaskID, Payload: payload}) + } + if !event.Valid() { + sendError(fmt.Errorf("invalid inbound AOP event")) + return + } + if event.Type != aop.TypeToolCall { + sendError(fmt.Errorf("unsupported inbound AOP event %q", event.Type)) + return + } + call, err := aop.DecodeData[aop.ToolCallData](event) + if err != nil { + sendError(fmt.Errorf("decode tool.call: %w", err)) + return + } + if msg.TaskID == "" || call.ToolCallID != msg.TaskID { + sendError(fmt.Errorf("tool.call correlation requires task_id == tool_call_id")) + return + } + if strings.TrimSpace(call.ToolName) == "" { + sendError(fmt.Errorf("tool.call tool_name is required")) + return + } if call.WorkDir != "" { ctx = tool.ContextWithInvocation(ctx, tool.Invocation{WorkDir: call.WorkDir}) } callID := msg.TaskID - if callID == "" { - callID = call.ToolCallID - } ctx = output.ContextWithCallID(ctx, callID) started := time.Now() result, execErr := executeCall(ctx, executor, call, dataBus, callID) - metadata := map[string]any{ - "tool_call_id": call.ToolCallID, - "tool_name": call.ToolName, - "duration_ms": int(time.Since(started).Milliseconds()), - } - var parts []aop.MessagePart - if execErr != nil { - parts = []aop.MessagePart{{Type: aop.PartText, Text: execErr.Error()}} - metadata["is_error"] = true - } else { - if result.Text() != "" { - parts = append(parts, aop.MessagePart{Type: aop.PartText, Text: result.Text()}) - } - if result.HasImages() { - for _, block := range result.Content { - if block.Type == "image" { - parts = append(parts, aop.MessagePart{Type: aop.PartImage, Image: &aop.ImageSource{Base64: block.Base64Data, MediaType: block.MimeType}}) - } - } - } - if result.Details != nil { - metadata["details"] = result.Details - } - metadata["terminate"] = result.Terminate - metadata["is_error"] = result.IsError - } - payload, _ := json.Marshal(webproto.CommandResultPayload{Parts: parts, Metadata: metadata}) - send(webproto.Message{Type: webproto.TypeCommandResult, TaskID: callID, Payload: payload}) + data := aop.ToolResultDataFromResult(call, result, execErr, time.Since(started)) + raw, _ := json.Marshal(data) + event.Type = aop.TypeToolResult + event.TS = time.Now().UTC().Format(time.RFC3339Nano) + event.Data = raw + payload, _ := json.Marshal(event) + send(webproto.Message{Type: webproto.TypeAOP, TaskID: callID, TurnID: event.TurnID, Payload: payload}) } // executeCall runs the tool call. Tools with foreground capability stream diff --git a/pkg/webagent/aop_tool_test.go b/pkg/webagent/aop_tool_test.go index 85212406..dee00af7 100644 --- a/pkg/webagent/aop_tool_test.go +++ b/pkg/webagent/aop_tool_test.go @@ -3,6 +3,7 @@ package webagent import ( "context" "encoding/json" + "strings" "testing" "time" @@ -28,33 +29,62 @@ func toolCommand(toolCallID, toolName string, args map[string]any) aop.ToolCallD } } -func decodeCommandResult(t *testing.T, msg webproto.Message) webproto.CommandResultPayload { +func toolEvent(t *testing.T, call aop.ToolCallData) aop.Event { t.Helper() - if msg.Type != webproto.TypeCommandResult { + data, err := json.Marshal(call) + if err != nil { + t.Fatal(err) + } + return aop.Event{ + Type: aop.TypeToolCall, TS: time.Now().UTC().Format(time.RFC3339Nano), + SessionID: "session-1", TurnID: call.ToolCallID, Agent: "worker", Data: data, + } +} + +func decodeToolResult(t *testing.T, msg webproto.Message) aop.ToolResultData { + t.Helper() + if msg.Type != webproto.TypeAOP { t.Fatalf("result envelope = %+v", msg) } - var result webproto.CommandResultPayload - if err := json.Unmarshal(msg.Payload, &result); err != nil { + var event aop.Event + if err := json.Unmarshal(msg.Payload, &event); err != nil { + t.Fatal(err) + } + if event.Type != aop.TypeToolResult { + t.Fatalf("result event = %+v", event) + } + result, err := aop.DecodeData[aop.ToolResultData](event) + if err != nil { t.Fatal(err) } return result } -func TestHandleToolCommand(t *testing.T) { +func TestHandleToolCallEvent(t *testing.T) { var got webproto.Message - HandleToolCommand(context.Background(), webproto.Message{Type: webproto.TypeCommand, TaskID: "call-1"}, - toolCommand("call-1", "echo", map[string]any{"value": "hello"}), + call := toolCommand("call-1", "echo", map[string]any{"value": "hello"}) + HandleToolCallEvent(context.Background(), webproto.Message{Type: webproto.TypeAOP, TaskID: "call-1"}, toolEvent(t, call), aopTestExecutor{}, nil, func(msg webproto.Message) { got = msg }) if got.TaskID != "call-1" { t.Fatalf("result envelope = %+v", got) } - result := decodeCommandResult(t, got) - if result.Metadata["tool_call_id"] != "call-1" || result.Metadata["tool_name"] != "echo" || len(result.Parts) != 1 { + result := decodeToolResult(t, got) + if result.ToolCallID != "call-1" || result.ToolName != "echo" || !strings.Contains(aop.ToolResultText(result.Content), "echo") { t.Fatalf("result data = %+v", result) } } +func TestHandleToolCallEventRejectsMismatchedCorrelation(t *testing.T) { + var got webproto.Message + call := toolCommand("call-1", "echo", map[string]any{"value": "hello"}) + HandleToolCallEvent(context.Background(), webproto.Message{Type: webproto.TypeAOP, TaskID: "other"}, toolEvent(t, call), + aopTestExecutor{}, nil, func(msg webproto.Message) { got = msg }) + if got.Type != webproto.TypeError || got.TaskID != "other" { + t.Fatalf("error envelope = %+v", got) + } +} + type recordingBash struct { command string options commands.BashExecOptions @@ -79,11 +109,11 @@ func (b *recordingBash) RunForegroundTool(_ context.Context, command string, opt return result, nil } -// TestHandleToolCommandForeground verifies that a foreground-capable tool is +// TestHandleToolCallEventForeground verifies that a foreground-capable tool is // run via RunForegroundTool, that output lines stream as tool.data progress // events correlated by the call session id, and that the tool.result carries // the text content plus structured Details. -func TestHandleToolCommandForeground(t *testing.T) { +func TestHandleToolCallEventForeground(t *testing.T) { reg := commands.NewRegistry() bash := &recordingBash{} reg.RegisterTool(bash) @@ -97,8 +127,8 @@ func TestHandleToolCommandForeground(t *testing.T) { }) var got webproto.Message - HandleToolCommand(context.Background(), webproto.Message{Type: webproto.TypeCommand, TaskID: "task-1"}, - toolCommand("call-1", "bash", map[string]any{"command": "echo test", "timeout": 7}), + call := toolCommand("task-1", "bash", map[string]any{"command": "echo test", "timeout": 7}) + HandleToolCallEvent(context.Background(), webproto.Message{Type: webproto.TypeAOP, TaskID: "task-1"}, toolEvent(t, call), reg, dataBus, func(msg webproto.Message) { got = msg }) if bash.command != "echo test" || bash.options.Timeout != 7*time.Second { @@ -108,11 +138,11 @@ func TestHandleToolCommandForeground(t *testing.T) { t.Fatalf("progress events = %+v", progress) } - result := decodeCommandResult(t, got) - if isError, _ := result.Metadata["is_error"].(bool); isError || len(result.Parts) == 0 || result.Parts[0].Text != "streamed" { + result := decodeToolResult(t, got) + if result.IsError || aop.ToolResultText(result.Content) != "streamed" { t.Fatalf("result data = %+v", result) } - details, _ := json.Marshal(result.Metadata["details"]) + details, _ := json.Marshal(result.Details) var structured output.Result if err := json.Unmarshal(details, &structured); err != nil { t.Fatalf("decode structured details: %v", err) diff --git a/pkg/webagent/connection.go b/pkg/webagent/connection.go index 4ce55c9e..071d96e1 100644 --- a/pkg/webagent/connection.go +++ b/pkg/webagent/connection.go @@ -301,28 +301,31 @@ func connectOnce(ctx context.Context, cc connectionConfig, logger telemetry.Logg } case webproto.TypeCommand: - var command webproto.CommandPayload - if json.Unmarshal(msg.Payload, &command) != nil { - continue - } - if command.ToolCall != nil { - taskCtx, cancel := context.WithCancel(connectionCtx) - mu.Lock() - execTasks[msg.TaskID] = cancel - mu.Unlock() - go func(m webproto.Message, call aop.ToolCallData) { - defer cancel() - defer func() { - mu.Lock() - delete(execTasks, m.TaskID) - mu.Unlock() - }() - HandleToolCommand(taskCtx, m, call, cc.Registry, cc.DataBus, send) - }(msg, *command.ToolCall) - } else if cc.Chat != nil { + if cc.Chat != nil { cc.Chat.HandleProtocol(connectionCtx, msg, send) } + case webproto.TypeAOP: + var event aop.Event + if err := json.Unmarshal(msg.Payload, &event); err != nil { + payload, _ := json.Marshal(webproto.ErrorPayload{Message: "decode AOP: " + err.Error()}) + send(webproto.Message{Type: webproto.TypeError, TaskID: msg.TaskID, Payload: payload}) + continue + } + taskCtx, cancel := context.WithCancel(connectionCtx) + mu.Lock() + execTasks[msg.TaskID] = cancel + mu.Unlock() + go func(m webproto.Message, event aop.Event) { + defer cancel() + defer func() { + mu.Lock() + delete(execTasks, m.TaskID) + mu.Unlock() + }() + HandleToolCallEvent(taskCtx, m, event, cc.Registry, cc.DataBus, send) + }(msg, event) + case "upload": if cc.Chat != nil { go cc.Chat.HandleUpload(msg, send) diff --git a/pkg/webagent/toolnode_test.go b/pkg/webagent/toolnode_test.go index 7d988167..7a7cb120 100644 --- a/pkg/webagent/toolnode_test.go +++ b/pkg/webagent/toolnode_test.go @@ -29,7 +29,7 @@ type hubScript struct { t *testing.T registered chan webproto.RegisterPayload - toolResult chan webproto.CommandResultPayload + toolResult chan aop.ToolResultData progress chan string fileData chan []byte toolData chan webproto.Message @@ -39,7 +39,7 @@ func newHubScript(t *testing.T) *hubScript { return &hubScript{ t: t, registered: make(chan webproto.RegisterPayload, 1), - toolResult: make(chan webproto.CommandResultPayload, 1), + toolResult: make(chan aop.ToolResultData, 1), progress: make(chan string, 16), fileData: make(chan []byte, 1), toolData: make(chan webproto.Message, 4), @@ -81,10 +81,15 @@ func (h *hubScript) serveHTTP(w http.ResponseWriter, r *http.Request) { return } switch msg.Type { - case webproto.TypeCommandResult: - var result webproto.CommandResultPayload - if err := json.Unmarshal(msg.Payload, &result); err != nil { - h.t.Errorf("command.result: %v", err) + case webproto.TypeAOP: + var event aop.Event + if err := json.Unmarshal(msg.Payload, &event); err != nil || event.Type != aop.TypeToolResult { + h.t.Errorf("tool.result: event=%+v err=%v", event, err) + return + } + result, err := aop.DecodeData[aop.ToolResultData](event) + if err != nil { + h.t.Errorf("tool.result data: %v", err) return } h.toolResult <- result @@ -113,12 +118,13 @@ func (h *hubScript) serveHTTP(w http.ResponseWriter, r *http.Request) { // drive issues the server→runner calls once the connection is live. func (h *hubScript) drive(conn *websocket.Conn) { call := aop.ToolCallData{ - ToolCallID: "call-1", + ToolCallID: "exec-1", ToolName: "bash", Args: map[string]any{"command": "echo hello"}, } - payload, _ := json.Marshal(webproto.CommandPayload{SessionID: "exec-1", ToolCall: &call}) - if err := conn.WriteJSON(webproto.Message{Type: webproto.TypeCommand, TaskID: "exec-1", Payload: payload}); err != nil { + event := toolEvent(h.t, call) + payload, _ := json.Marshal(event) + if err := conn.WriteJSON(webproto.Message{Type: webproto.TypeAOP, TaskID: "exec-1", TurnID: "exec-1", Payload: payload}); err != nil { return } } @@ -190,9 +196,9 @@ func TestRunToolNodeWireInterop(t *testing.T) { if line != "streamed" { t.Fatalf("progress line = %q", line) } - result := wait(t, hub.toolResult, "command.result") - if isError, _ := result.Metadata["is_error"].(bool); isError || result.Metadata["tool_call_id"] != "call-1" || result.Metadata["tool_name"] != "bash" { - t.Fatalf("command.result = %+v", result) + result := wait(t, hub.toolResult, "tool.result") + if result.IsError || result.ToolCallID != "exec-1" || result.ToolName != "bash" { + t.Fatalf("tool.result = %+v", result) } // tool.data rides the same connection, correlated by call ID. diff --git a/pkg/webproto/message.go b/pkg/webproto/message.go index 1669a00a..2de98f76 100644 --- a/pkg/webproto/message.go +++ b/pkg/webproto/message.go @@ -54,9 +54,8 @@ type RunPayload struct { } type CommandPayload struct { - SessionID string `json:"session_id"` - Line string `json:"line"` - ToolCall *aop.ToolCallData `json:"tool_call,omitempty"` + SessionID string `json:"session_id"` + Line string `json:"line"` } type CommandResultPayload struct { From 79fd75df9d5ec85a5875ab4c8829e94e120fe131 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sat, 25 Jul 2026 13:01:05 +0800 Subject: [PATCH 5/9] refactor(output): remove legacy agent timeline models --- core/output/record.go | 2 - core/output/timeline.go | 312 ++++++++++------------------------- core/output/timeline_test.go | 38 ++++- 3 files changed, 123 insertions(+), 229 deletions(-) diff --git a/core/output/record.go b/core/output/record.go index 93bcb2ab..a75c0bf9 100644 --- a/core/output/record.go +++ b/core/output/record.go @@ -18,7 +18,6 @@ const ( TypeSpray RecordType = "spray" TypeZombie RecordType = "zombie" TypeNeutron RecordType = "neutron" - TypeAgent RecordType = "agent" TypeScanEnd RecordType = "scan_end" TypeError RecordType = "error" @@ -61,7 +60,6 @@ func (r Record) Marshal() []byte { return b } - func ParseRecord(line []byte) (Record, error) { var r Record err := json.Unmarshal(line, &r) diff --git a/core/output/timeline.go b/core/output/timeline.go index 30371dc0..6280956a 100644 --- a/core/output/timeline.go +++ b/core/output/timeline.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/utils/parsers" "github.com/charmbracelet/glamour" "github.com/muesli/termenv" @@ -52,9 +53,12 @@ func ParseTimelineFile(path string) ([]TimelineEntry, error) { } func parseLine(line []byte) (TimelineEntry, bool) { - var event AOPTimelineEntry + var event aop.Event if json.Unmarshal(line, &event) == nil && event.Valid() { - return TimelineEntry{Timestamp: event.Timestamp, Type: event.Type, Data: &event}, true + timestamp, err := time.Parse(time.RFC3339Nano, event.TS) + if err == nil { + return TimelineEntry{Timestamp: timestamp, Type: event.Type, Data: &event}, true + } } rec, err := ParseRecord(line) if err != nil || rec.Type == "" { @@ -77,8 +81,6 @@ func parseRecordData(rec Record) any { return unmarshalItem[parsers.GOGOResult](rec.Data) case TypeSpray: return unmarshalItem[parsers.SprayResult](rec.Data) - case TypeAgent: - return unmarshalItem[AgentEvent](rec.Data) case TypeScanEnd: return unmarshalItem[ScanEnd](rec.Data) } @@ -122,10 +124,8 @@ func BuildTimelineMarkdown(entries []TimelineEntry) string { writeSprayMarkdown(&sb, d) case *parsers.Loot: writeLootMarkdown(&sb, d) - case *AgentEvent: - d.writeMarkdown(&sb) - case *AOPTimelineEntry: - d.writeMarkdown(&sb) + case *aop.Event: + writeAOPMarkdown(&sb, d) case *ScanEnd: d.writeMarkdown(&sb) } @@ -162,109 +162,6 @@ func writeHeader(sb *strings.Builder, sess *sessionMeta) { } } -// --------------------------------------------------------------------------- -// AgentEvent -// --------------------------------------------------------------------------- - -type AgentEvent struct { - Type string `json:"type"` - SessionID string `json:"session_id"` - ParentSessionID string `json:"parent_session_id"` - Turn int `json:"turn"` - ToolCallID string `json:"tool_call_id"` - ToolName string `json:"tool_name"` - Arguments string `json:"arguments"` - Result string `json:"result"` - IsError bool `json:"is_error"` - Error string `json:"error"` - Stop string `json:"stop"` - Message *AgentEventMsg `json:"message"` - ToolResults []AgentEventMsg `json:"tool_results"` - Usage *AgentEventUsage `json:"usage"` - ContextTokens int `json:"context_tokens"` - NewMessages int `json:"new_messages"` - RequestModel string `json:"request_model"` - RequestMessages int `json:"request_messages"` - RequestTools int `json:"request_tools"` -} - -type AgentEventMsg struct { - Role string `json:"role"` - Content string `json:"content"` - ToolCalls []agentToolCall `json:"tool_calls"` - ToolCallID string `json:"tool_call_id"` -} - -type AgentEventUsage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` - CacheReadTokens int `json:"cache_read_tokens"` - CacheWriteTokens int `json:"cache_write_tokens"` -} - -type agentToolCall struct { - ID string `json:"id"` - Type string `json:"type"` - Function struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - } `json:"function"` -} - -func (ev *AgentEvent) writeMarkdown(sb *strings.Builder) { - switch ev.Type { - case "turn_start": - sb.WriteString(fmt.Sprintf("## Turn %d\n\n", ev.Turn)) - - case "message_end": - if ev.Message == nil { - return - } - switch ev.Message.Role { - case "user": - sb.WriteString(fmt.Sprintf("> %s\n\n", TruncateStr(ev.Message.Content, 200))) - case "assistant": - if len(ev.Message.ToolCalls) > 0 { - return - } - if ev.Message.Content != "" { - sb.WriteString(ev.Message.Content + "\n\n") - } - } - - case "tool_execution_start": - args := summarizeToolArgs(ev.ToolName, ev.Arguments) - if args != "" { - sb.WriteString(fmt.Sprintf("- **%s** `%s`\n", ev.ToolName, args)) - } else { - sb.WriteString(fmt.Sprintf("- **%s**\n", ev.ToolName)) - } - - case "tool_execution_end": - if ev.IsError || ev.Error != "" { - errMsg := ev.Error - if errMsg == "" { - errMsg = TruncateStr(ev.Result, 120) - } - sb.WriteString(fmt.Sprintf(" - ✗ `%s`\n", TruncateStr(errMsg, 120))) - } else { - sb.WriteString(fmt.Sprintf(" - ✓ %s\n", compactResult(ev.Result, 150))) - } - - case "turn_end": - if ev.Usage != nil && ev.Usage.TotalTokens > 0 { - usage := fmt.Sprintf("*%d tokens", ev.Usage.TotalTokens) - if ev.Usage.CacheReadTokens > 0 && ev.Usage.PromptTokens > 0 { - pct := float64(ev.Usage.CacheReadTokens) / float64(ev.Usage.PromptTokens) * 100 - usage += fmt.Sprintf(", cache %.0f%%", pct) - } - sb.WriteString("\n" + usage + "*\n") - } - sb.WriteString("\n") - } -} - // --------------------------------------------------------------------------- // Scan types // --------------------------------------------------------------------------- @@ -299,56 +196,34 @@ func collectSessionMeta(entries []TimelineEntry) sessionMeta { var m sessionMeta for _, e := range entries { switch d := e.Data.(type) { - case *AgentEvent: + case *aop.Event: if m.id == "" { m.id = d.SessionID - m.parentID = d.ParentSessionID - } - if d.RequestModel != "" && m.model == "" { - m.model = d.RequestModel } switch d.Type { - case "agent_start": + case aop.TypeSessionStart: m.startTS = e.Timestamp - case "agent_end": - m.endTS = e.Timestamp - m.stop = d.Stop - case "turn_start": - m.turns++ - case "turn_end": - if d.Usage != nil { - m.totalTokens = d.Usage.TotalTokens + if data, err := aop.DecodeData[aop.SessionStartData](*d); err == nil { + m.parentID = data.ParentSessionID + if data.Model != "" && m.model == "" { + m.model = data.Model + } } - } - case *AOPTimelineEntry: - switch d.Type { - case "session.start": - m.startTS = e.Timestamp - var sd struct { - Model string `json:"model"` - } - _ = json.Unmarshal(d.Data, &sd) - if sd.Model != "" && m.model == "" { - m.model = sd.Model - } - case "session.end": + case aop.TypeSessionEnd: m.endTS = e.Timestamp - case "turn.start": + case aop.TypeTurnStart: m.turns++ - case "turn.end": + case aop.TypeTurnEnd: m.endTS = e.Timestamp - var td struct { - Stop string `json:"stop"` - } - _ = json.Unmarshal(d.Data, &td) - m.stop = td.Stop - case "usage": - var ud struct { - TotalTokens int `json:"total_tokens"` + if data, err := aop.DecodeData[aop.TurnEndData](*d); err == nil { + m.stop = data.Stop + if data.Usage != nil && data.Usage.TotalTokens > 0 { + m.totalTokens = data.Usage.TotalTokens + } } - _ = json.Unmarshal(d.Data, &ud) - if ud.TotalTokens > 0 { - m.totalTokens = ud.TotalTokens + case aop.TypeUsage: + if data, err := aop.DecodeData[aop.UsageData](*d); err == nil && data.TotalTokens > 0 { + m.totalTokens = data.TotalTokens } } } @@ -476,110 +351,97 @@ func compactResult(result string, maxLen int) string { return TruncateStr(first, maxLen-20) + fmt.Sprintf(" (+%d lines)", len(lines)-1) } -// --------------------------------------------------------------------------- -// AOP event support -// --------------------------------------------------------------------------- - -type AOPTimelineEntry struct { - Type string `json:"type"` - Timestamp time.Time `json:"ts"` - SessionID string `json:"session_id"` - TurnID string `json:"turn_id,omitempty"` - Agent string `json:"agent"` - Data json.RawMessage `json:"data"` -} - -func (e AOPTimelineEntry) Valid() bool { - return e.Type != "" && !e.Timestamp.IsZero() && e.SessionID != "" && e.Agent != "" && len(e.Data) > 0 -} - -func (e *AOPTimelineEntry) writeMarkdown(sb *strings.Builder) { - switch e.Type { - case "turn.start": - sb.WriteString(fmt.Sprintf("## Run %s\n\n", e.TurnID)) +func writeAOPMarkdown(sb *strings.Builder, event *aop.Event) { + if event == nil { + return + } + switch event.Type { + case aop.TypeTurnStart: + sb.WriteString(fmt.Sprintf("## Run %s\n\n", event.TurnID)) - case "text": - var d struct { - Content string `json:"content"` - Role string `json:"role"` - Delta bool `json:"delta"` + case aop.TypeMessage: + data, err := aop.DecodeData[aop.MessageData](*event) + if err != nil { + return } - _ = json.Unmarshal(e.Data, &d) - if d.Delta || d.Content == "" { + var textParts []string + for _, part := range data.Parts { + if part.Type == aop.PartText && part.Text != "" { + textParts = append(textParts, part.Text) + } + } + text := strings.Join(textParts, "\n") + if text == "" { return } - if d.Role == "user" { - sb.WriteString(fmt.Sprintf("> %s\n\n", TruncateStr(d.Content, 200))) + if data.Role == "user" { + sb.WriteString(fmt.Sprintf("> %s\n\n", TruncateStr(text, 200))) } else { - sb.WriteString(d.Content + "\n\n") + sb.WriteString(text + "\n\n") } - case "tool.call": - var d struct { - ToolName string `json:"tool_name"` - Args any `json:"args"` + case aop.TypeToolCall: + data, err := aop.DecodeData[aop.ToolCallData](*event) + if err != nil { + return } - _ = json.Unmarshal(e.Data, &d) argsStr := "" - switch a := d.Args.(type) { + switch args := data.Args.(type) { case string: - argsStr = a + argsStr = args case map[string]any: - raw, _ := json.Marshal(a) + raw, _ := json.Marshal(args) argsStr = string(raw) } - args := summarizeToolArgs(d.ToolName, argsStr) - if args != "" { - sb.WriteString(fmt.Sprintf("- **%s** `%s`\n", d.ToolName, args)) + summary := summarizeToolArgs(data.ToolName, argsStr) + if summary != "" { + sb.WriteString(fmt.Sprintf("- **%s** `%s`\n", data.ToolName, summary)) } else { - sb.WriteString(fmt.Sprintf("- **%s**\n", d.ToolName)) + sb.WriteString(fmt.Sprintf("- **%s**\n", data.ToolName)) } - case "tool.result": - var d struct { - ToolName string `json:"tool_name"` - Content any `json:"content"` - IsError bool `json:"is_error"` - } - _ = json.Unmarshal(e.Data, &d) - result := "" - if s, ok := d.Content.(string); ok { - result = s + case aop.TypeToolResult: + data, err := aop.DecodeData[aop.ToolResultData](*event) + if err != nil { + return } - if d.IsError { + result := aop.ToolResultText(data.Content) + if data.IsError { sb.WriteString(fmt.Sprintf(" - ✗ `%s`\n", TruncateStr(result, 120))) } else { sb.WriteString(fmt.Sprintf(" - ✓ %s\n", compactResult(result, 150))) } - case "usage": - var d struct { - TotalTokens int `json:"total_tokens"` - CacheReadTokens int `json:"cache_read_tokens"` - InputTokens int `json:"input_tokens"` + case aop.TypeUsage: + data, err := aop.DecodeData[aop.UsageData](*event) + if err != nil { + return } - _ = json.Unmarshal(e.Data, &d) - if d.TotalTokens > 0 { - usage := fmt.Sprintf("*%d tokens", d.TotalTokens) - if d.CacheReadTokens > 0 && d.InputTokens > 0 { - pct := float64(d.CacheReadTokens) / float64(d.InputTokens) * 100 + if data.TotalTokens > 0 { + usage := fmt.Sprintf("*%d tokens", data.TotalTokens) + if data.CacheReadTokens > 0 && data.InputTokens > 0 { + pct := float64(data.CacheReadTokens) / float64(data.InputTokens) * 100 usage += fmt.Sprintf(", cache %.0f%%", pct) } sb.WriteString("\n" + usage + "*\n\n") } - case "turn.end": - var d struct { - Stop string `json:"stop"` + case aop.TypeError: + data, err := aop.DecodeData[aop.ErrorData](*event) + if err == nil && data.Message != "" { + sb.WriteString(fmt.Sprintf("\n> **error:** %s\n\n", data.Message)) + } + + case aop.TypeTurnEnd: + data, err := aop.DecodeData[aop.TurnEndData](*event) + if err == nil { + sb.WriteString(fmt.Sprintf("\n> **run done** (stop=%s)\n\n", data.Stop)) } - _ = json.Unmarshal(e.Data, &d) - sb.WriteString(fmt.Sprintf("\n> **run done** (stop=%s)\n\n", d.Stop)) - case "session.end": - var d struct { - Reason string `json:"reason"` + case aop.TypeSessionEnd: + data, err := aop.DecodeData[aop.SessionEndData](*event) + if err == nil { + sb.WriteString(fmt.Sprintf("\n> **session closed** (reason=%s)\n\n", data.Reason)) } - _ = json.Unmarshal(e.Data, &d) - sb.WriteString(fmt.Sprintf("\n> **session closed** (reason=%s)\n\n", d.Reason)) } } diff --git a/core/output/timeline_test.go b/core/output/timeline_test.go index b92aec35..342643bd 100644 --- a/core/output/timeline_test.go +++ b/core/output/timeline_test.go @@ -1,18 +1,22 @@ package output import ( + "encoding/json" "strings" "testing" + "time" + + "github.com/chainreactors/aiscan/pkg/aop" ) func TestParseLineReadsNativeAOPEnvelope(t *testing.T) { - raw := []byte(`{"type":"text","ts":"2026-07-20T00:00:00Z","session_id":"session-1","agent":"aiscan","data":{"content":"hello","role":"assistant"}}`) + raw := []byte(`{"type":"message","ts":"2026-07-20T00:00:00Z","session_id":"session-1","agent":"aiscan","data":{"message_id":"m-1","role":"assistant","parts":[{"type":"text","text":"hello"}]}}`) entry, ok := parseLine(raw) if !ok { t.Fatal("native AOP envelope was not parsed") } - if _, ok := entry.Data.(*AOPTimelineEntry); !ok { + if _, ok := entry.Data.(*aop.Event); !ok { t.Fatalf("entry data type = %T", entry.Data) } if markdown := BuildTimelineMarkdown([]TimelineEntry{entry}); !strings.Contains(markdown, "hello") { @@ -20,6 +24,36 @@ func TestParseLineReadsNativeAOPEnvelope(t *testing.T) { } } +func TestTimelineRendersStructuredToolResult(t *testing.T) { + data, _ := json.Marshal(aop.ToolResultData{ + ToolCallID: "call-1", ToolName: "scan", + Content: aop.ToolResultContent{Content: "three ports", Images: []aop.ImageSource{{MediaType: "image/png", Base64: "eA=="}}}, + }) + event := aop.Event{ + Type: aop.TypeToolResult, TS: "2026-07-20T00:00:00Z", SessionID: "session-1", TurnID: "turn-1", Agent: "aiscan", Data: data, + } + markdown := BuildTimelineMarkdown([]TimelineEntry{{Timestamp: mustTimelineTime(t, event.TS), Type: event.Type, Data: &event}}) + if !strings.Contains(markdown, "three ports") { + t.Fatalf("timeline markdown = %q", markdown) + } +} + +func mustTimelineTime(t *testing.T, value string) time.Time { + t.Helper() + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + t.Fatal(err) + } + return parsed +} + +func TestParseLineRejectsLegacyAgentRecord(t *testing.T) { + record := NewRecord(RecordType("agent"), map[string]any{"type": "message_end"}) + if _, ok := parseLine(record.Marshal()); ok { + t.Fatal("legacy agent record should not be accepted") + } +} + func TestParseLineRejectsLegacyAOPRecordPrefix(t *testing.T) { record := NewRecord(RecordType("aop.text"), map[string]any{"content": "legacy"}) if _, ok := parseLine(record.Marshal()); ok { From 01590a4ec9005762201d0ece635468ebdfc8d5f8 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sat, 25 Jul 2026 13:06:23 +0800 Subject: [PATCH 6/9] refactor: promote tools to top-level packages --- cmd/agent/imports.go | 2 +- cmd/aiscan/imports.go | 20 ++-- cmd/aiscan/imports_full.go | 6 +- cmd/aiscan/setup.go | 4 +- cmd/runner/imports.go | 14 +-- cmd/runner/main.go | 2 +- core/config/scanner_katana.go | 2 +- core/runner/runner.go | 2 +- docs/development.md | 20 ++-- pkg/agent/probe/conn.go | 2 +- pkg/web/service.go | 2 +- tools/README.md | 102 ++++++++++++++++++ {pkg/tools => tools}/arsenal/arsenal_tool.go | 0 .../arsenal/arsenal_tool_test.go | 0 {pkg/tools => tools}/arsenal/register.go | 0 .../functional_integration_full_test.go | 4 +- .../functional_integration_test.go | 8 +- .../tools => tools}/functional_norace_test.go | 0 {pkg/tools => tools}/functional_race_test.go | 0 .../functional_regression_full_test.go | 6 +- .../functional_regression_test.go | 12 +-- .../functional_testkit_test.go | 0 {pkg/tools => tools}/gogo/gogo.go | 2 +- {pkg/tools => tools}/gogo/gogo_test.go | 0 {pkg/tools => tools}/gogo/register.go | 2 +- {pkg/tools => tools}/ioa/commands.go | 0 {pkg/tools => tools}/ioa/commands_test.go | 2 +- {pkg/tools => tools}/ioa/register.go | 0 {pkg/tools => tools}/katana/katana.go | 2 +- {pkg/tools => tools}/katana/register.go | 0 {pkg/tools => tools}/neutron/neutron.go | 4 +- {pkg/tools => tools}/neutron/neutron_test.go | 0 {pkg/tools => tools}/neutron/register.go | 2 +- {pkg/tools => tools}/neutron/sdk_stage.go | 2 +- {pkg/tools => tools}/passive/passive.go | 2 +- {pkg/tools => tools}/passive/passive_test.go | 2 +- {pkg/tools => tools}/passive/register.go | 2 +- {pkg/tools => tools}/playwright/advanced.go | 0 {pkg/tools => tools}/playwright/autofill.go | 0 {pkg/tools => tools}/playwright/browser.go | 0 .../playwright/browser_test.go | 0 {pkg/tools => tools}/playwright/dialog.go | 0 {pkg/tools => tools}/playwright/discover.go | 0 {pkg/tools => tools}/playwright/headless.go | 0 {pkg/tools => tools}/playwright/interact.go | 0 {pkg/tools => tools}/playwright/navigation.go | 0 {pkg/tools => tools}/playwright/recorder.go | 0 .../playwright/recorder_test.go | 0 {pkg/tools => tools}/playwright/register.go | 0 {pkg/tools => tools}/playwright/session.go | 0 {pkg/tools => tools}/playwright/storage.go | 0 .../playwright/storage_test.go | 0 {pkg/tools => tools}/playwright/tabs.go | 0 .../playwright/testharness/.gitignore | 0 .../playwright/testharness/conftest.py | 0 .../testharness/fixtures/dynamic.html | 0 .../testharness/fixtures/forms.html | 0 .../fixtures/headless-extract.yaml | 0 .../fixtures/headless-template.yaml | 0 .../testharness/fixtures/login.html | 0 .../testharness/fixtures/navigation.html | 0 .../testharness/fixtures/page2.html | 0 .../playwright/testharness/pw_driver.go | 4 +- .../playwright/testharness/test_cli_parity.py | 0 .../playwright/testharness/test_dispatch.py | 0 .../playwright/testharness/test_extraction.py | 0 .../playwright/testharness/test_files.py | 0 .../playwright/testharness/test_focus.py | 0 .../playwright/testharness/test_headers.py | 0 .../testharness/test_headless_template.py | 0 .../testharness/test_interaction.py | 0 .../playwright/testharness/test_navigation.py | 0 .../playwright/testharness/test_route.py | 0 .../playwright/testharness/test_viewport.py | 0 .../playwright/testharness/test_wait.py | 0 {pkg/tools => tools}/proton/command.go | 2 +- {pkg/tools => tools}/proton/command_test.go | 2 +- {pkg/tools => tools}/proton/register.go | 0 {pkg/tools => tools}/proton/register_test.go | 0 {pkg/tools => tools}/proxy/command.go | 0 {pkg/tools => tools}/proxy/command_test.go | 0 {pkg/tools => tools}/proxy/mitm.go | 0 {pkg/tools => tools}/proxy/mitm_test.go | 0 .../tools => tools}/proxy/race_norace_test.go | 0 {pkg/tools => tools}/proxy/race_test.go | 0 .../tools => tools}/proxy/register_command.go | 0 {pkg/tools => tools}/proxy/state.go | 0 {pkg/tools => tools}/proxy/state_test.go | 0 {pkg/tools => tools}/register_command.go | 4 +- .../register_command_full_test.go | 2 +- .../register_command_integration_test.go | 6 +- {pkg/tools => tools}/register_command_test.go | 12 +-- {pkg/tools => tools}/scan/adapter.go | 4 +- {pkg/tools => tools}/scan/aggregate.go | 0 {pkg/tools => tools}/scan/aggregate_test.go | 0 {pkg/tools => tools}/scan/bridge.go | 2 +- {pkg/tools => tools}/scan/capability.go | 4 +- .../tools => tools}/scan/capability_katana.go | 26 ++--- .../scan/capability_katana_stub.go | 0 .../scan/capability_katana_test.go | 0 {pkg/tools => tools}/scan/collector.go | 2 +- {pkg/tools => tools}/scan/command.go | 6 +- {pkg/tools => tools}/scan/command_test.go | 4 +- {pkg/tools => tools}/scan/data_bus_test.go | 2 +- {pkg/tools => tools}/scan/engine/gogo.go | 0 {pkg/tools => tools}/scan/engine/gogo_test.go | 0 {pkg/tools => tools}/scan/engine/neutron.go | 0 .../scan/engine/race_norace_test.go | 0 {pkg/tools => tools}/scan/engine/race_test.go | 0 {pkg/tools => tools}/scan/engine/set.go | 0 {pkg/tools => tools}/scan/engine/set_test.go | 0 .../scan/engine/set_uncover_recon.go | 0 .../scan/engine/set_uncover_stub.go | 0 {pkg/tools => tools}/scan/engine/spray.go | 0 .../tools => tools}/scan/engine/spray_test.go | 0 {pkg/tools => tools}/scan/engine/uncover.go | 0 .../scan/engine/uncover_agents.go | 0 .../scan/engine/uncover_stub.go | 0 .../scan/engine/uncover_test.go | 0 {pkg/tools => tools}/scan/engine/zombie.go | 0 {pkg/tools => tools}/scan/event.go | 0 {pkg/tools => tools}/scan/http_auth.go | 0 {pkg/tools => tools}/scan/input.go | 0 {pkg/tools => tools}/scan/intent.go | 0 {pkg/tools => tools}/scan/jsonl_writer.go | 2 +- {pkg/tools => tools}/scan/options.go | 0 {pkg/tools => tools}/scan/output.go | 0 .../tools => tools}/scan/pipeline/pipeline.go | 0 .../scan/pipeline/pipeline_test.go | 0 {pkg/tools => tools}/scan/report.go | 0 {pkg/tools => tools}/scan/report_json.go | 0 {pkg/tools => tools}/scan/report_plain.go | 0 {pkg/tools => tools}/scan/scan_options.go | 0 {pkg/tools => tools}/scan/sco.go | 0 {pkg/tools => tools}/scan/sco_stub.go | 0 {pkg/tools => tools}/scan/sco_test.go | 2 +- {pkg/tools => tools}/scan/structured.go | 0 {pkg/tools => tools}/scan/target.go | 0 {pkg/tools => tools}/scan/verify.go | 0 {pkg/tools => tools}/search/cyberhub.go | 0 {pkg/tools => tools}/search/cyberhub_test.go | 0 {pkg/tools => tools}/search/fetch.go | 0 {pkg/tools => tools}/search/fetch_test.go | 0 {pkg/tools => tools}/search/register.go | 2 +- {pkg/tools => tools}/search/tavily.go | 0 {pkg/tools => tools}/search/tavily_test.go | 0 {pkg/tools => tools}/search/websearch.go | 0 {pkg/tools => tools}/search/websearch_tool.go | 0 {pkg/tools => tools}/spray/register.go | 2 +- {pkg/tools => tools}/spray/spray.go | 2 +- {pkg/tools => tools}/spray/spray_test.go | 0 {pkg/tools => tools}/toolargs/base.go | 0 {pkg/tools => tools}/toolargs/flags.go | 0 {pkg/tools => tools}/toolargs/help.go | 0 {pkg/tools => tools}/toolargs/normalize.go | 0 {pkg/tools => tools}/toolargs/resolve.go | 0 {pkg/tools => tools}/zombie/register.go | 2 +- {pkg/tools => tools}/zombie/zombie.go | 2 +- {pkg/tools => tools}/zombie/zombie_test.go | 0 159 files changed, 215 insertions(+), 111 deletions(-) create mode 100644 tools/README.md rename {pkg/tools => tools}/arsenal/arsenal_tool.go (100%) rename {pkg/tools => tools}/arsenal/arsenal_tool_test.go (100%) rename {pkg/tools => tools}/arsenal/register.go (100%) rename {pkg/tools => tools}/functional_integration_full_test.go (92%) rename {pkg/tools => tools}/functional_integration_test.go (94%) rename {pkg/tools => tools}/functional_norace_test.go (100%) rename {pkg/tools => tools}/functional_race_test.go (100%) rename {pkg/tools => tools}/functional_regression_full_test.go (94%) rename {pkg/tools => tools}/functional_regression_test.go (97%) rename {pkg/tools => tools}/functional_testkit_test.go (100%) rename {pkg/tools => tools}/gogo/gogo.go (98%) rename {pkg/tools => tools}/gogo/gogo_test.go (100%) rename {pkg/tools => tools}/gogo/register.go (93%) rename {pkg/tools => tools}/ioa/commands.go (100%) rename {pkg/tools => tools}/ioa/commands_test.go (99%) rename {pkg/tools => tools}/ioa/register.go (100%) rename {pkg/tools => tools}/katana/katana.go (99%) rename {pkg/tools => tools}/katana/register.go (100%) rename {pkg/tools => tools}/neutron/neutron.go (99%) rename {pkg/tools => tools}/neutron/neutron_test.go (100%) rename {pkg/tools => tools}/neutron/register.go (93%) rename {pkg/tools => tools}/neutron/sdk_stage.go (99%) rename {pkg/tools => tools}/passive/passive.go (99%) rename {pkg/tools => tools}/passive/passive_test.go (98%) rename {pkg/tools => tools}/passive/register.go (94%) rename {pkg/tools => tools}/playwright/advanced.go (100%) rename {pkg/tools => tools}/playwright/autofill.go (100%) rename {pkg/tools => tools}/playwright/browser.go (100%) rename {pkg/tools => tools}/playwright/browser_test.go (100%) rename {pkg/tools => tools}/playwright/dialog.go (100%) rename {pkg/tools => tools}/playwright/discover.go (100%) rename {pkg/tools => tools}/playwright/headless.go (100%) rename {pkg/tools => tools}/playwright/interact.go (100%) rename {pkg/tools => tools}/playwright/navigation.go (100%) rename {pkg/tools => tools}/playwright/recorder.go (100%) rename {pkg/tools => tools}/playwright/recorder_test.go (100%) rename {pkg/tools => tools}/playwright/register.go (100%) rename {pkg/tools => tools}/playwright/session.go (100%) rename {pkg/tools => tools}/playwright/storage.go (100%) rename {pkg/tools => tools}/playwright/storage_test.go (100%) rename {pkg/tools => tools}/playwright/tabs.go (100%) rename {pkg/tools => tools}/playwright/testharness/.gitignore (100%) rename {pkg/tools => tools}/playwright/testharness/conftest.py (100%) rename {pkg/tools => tools}/playwright/testharness/fixtures/dynamic.html (100%) rename {pkg/tools => tools}/playwright/testharness/fixtures/forms.html (100%) rename {pkg/tools => tools}/playwright/testharness/fixtures/headless-extract.yaml (100%) rename {pkg/tools => tools}/playwright/testharness/fixtures/headless-template.yaml (100%) rename {pkg/tools => tools}/playwright/testharness/fixtures/login.html (100%) rename {pkg/tools => tools}/playwright/testharness/fixtures/navigation.html (100%) rename {pkg/tools => tools}/playwright/testharness/fixtures/page2.html (100%) rename {pkg/tools => tools}/playwright/testharness/pw_driver.go (91%) rename {pkg/tools => tools}/playwright/testharness/test_cli_parity.py (100%) rename {pkg/tools => tools}/playwright/testharness/test_dispatch.py (100%) rename {pkg/tools => tools}/playwright/testharness/test_extraction.py (100%) rename {pkg/tools => tools}/playwright/testharness/test_files.py (100%) rename {pkg/tools => tools}/playwright/testharness/test_focus.py (100%) rename {pkg/tools => tools}/playwright/testharness/test_headers.py (100%) rename {pkg/tools => tools}/playwright/testharness/test_headless_template.py (100%) rename {pkg/tools => tools}/playwright/testharness/test_interaction.py (100%) rename {pkg/tools => tools}/playwright/testharness/test_navigation.py (100%) rename {pkg/tools => tools}/playwright/testharness/test_route.py (100%) rename {pkg/tools => tools}/playwright/testharness/test_viewport.py (100%) rename {pkg/tools => tools}/playwright/testharness/test_wait.py (100%) rename {pkg/tools => tools}/proton/command.go (99%) rename {pkg/tools => tools}/proton/command_test.go (99%) rename {pkg/tools => tools}/proton/register.go (100%) rename {pkg/tools => tools}/proton/register_test.go (100%) rename {pkg/tools => tools}/proxy/command.go (100%) rename {pkg/tools => tools}/proxy/command_test.go (100%) rename {pkg/tools => tools}/proxy/mitm.go (100%) rename {pkg/tools => tools}/proxy/mitm_test.go (100%) rename {pkg/tools => tools}/proxy/race_norace_test.go (100%) rename {pkg/tools => tools}/proxy/race_test.go (100%) rename {pkg/tools => tools}/proxy/register_command.go (100%) rename {pkg/tools => tools}/proxy/state.go (100%) rename {pkg/tools => tools}/proxy/state_test.go (100%) rename {pkg/tools => tools}/register_command.go (89%) rename {pkg/tools => tools}/register_command_full_test.go (93%) rename {pkg/tools => tools}/register_command_integration_test.go (93%) rename {pkg/tools => tools}/register_command_test.go (95%) rename {pkg/tools => tools}/scan/adapter.go (99%) rename {pkg/tools => tools}/scan/aggregate.go (100%) rename {pkg/tools => tools}/scan/aggregate_test.go (100%) rename {pkg/tools => tools}/scan/bridge.go (96%) rename {pkg/tools => tools}/scan/capability.go (98%) rename {pkg/tools => tools}/scan/capability_katana.go (87%) rename {pkg/tools => tools}/scan/capability_katana_stub.go (100%) rename {pkg/tools => tools}/scan/capability_katana_test.go (100%) rename {pkg/tools => tools}/scan/collector.go (99%) rename {pkg/tools => tools}/scan/command.go (98%) rename {pkg/tools => tools}/scan/command_test.go (99%) rename {pkg/tools => tools}/scan/data_bus_test.go (95%) rename {pkg/tools => tools}/scan/engine/gogo.go (100%) rename {pkg/tools => tools}/scan/engine/gogo_test.go (100%) rename {pkg/tools => tools}/scan/engine/neutron.go (100%) rename {pkg/tools => tools}/scan/engine/race_norace_test.go (100%) rename {pkg/tools => tools}/scan/engine/race_test.go (100%) rename {pkg/tools => tools}/scan/engine/set.go (100%) rename {pkg/tools => tools}/scan/engine/set_test.go (100%) rename {pkg/tools => tools}/scan/engine/set_uncover_recon.go (100%) rename {pkg/tools => tools}/scan/engine/set_uncover_stub.go (100%) rename {pkg/tools => tools}/scan/engine/spray.go (100%) rename {pkg/tools => tools}/scan/engine/spray_test.go (100%) rename {pkg/tools => tools}/scan/engine/uncover.go (100%) rename {pkg/tools => tools}/scan/engine/uncover_agents.go (100%) rename {pkg/tools => tools}/scan/engine/uncover_stub.go (100%) rename {pkg/tools => tools}/scan/engine/uncover_test.go (100%) rename {pkg/tools => tools}/scan/engine/zombie.go (100%) rename {pkg/tools => tools}/scan/event.go (100%) rename {pkg/tools => tools}/scan/http_auth.go (100%) rename {pkg/tools => tools}/scan/input.go (100%) rename {pkg/tools => tools}/scan/intent.go (100%) rename {pkg/tools => tools}/scan/jsonl_writer.go (97%) rename {pkg/tools => tools}/scan/options.go (100%) rename {pkg/tools => tools}/scan/output.go (100%) rename {pkg/tools => tools}/scan/pipeline/pipeline.go (100%) rename {pkg/tools => tools}/scan/pipeline/pipeline_test.go (100%) rename {pkg/tools => tools}/scan/report.go (100%) rename {pkg/tools => tools}/scan/report_json.go (100%) rename {pkg/tools => tools}/scan/report_plain.go (100%) rename {pkg/tools => tools}/scan/scan_options.go (100%) rename {pkg/tools => tools}/scan/sco.go (100%) rename {pkg/tools => tools}/scan/sco_stub.go (100%) rename {pkg/tools => tools}/scan/sco_test.go (97%) rename {pkg/tools => tools}/scan/structured.go (100%) rename {pkg/tools => tools}/scan/target.go (100%) rename {pkg/tools => tools}/scan/verify.go (100%) rename {pkg/tools => tools}/search/cyberhub.go (100%) rename {pkg/tools => tools}/search/cyberhub_test.go (100%) rename {pkg/tools => tools}/search/fetch.go (100%) rename {pkg/tools => tools}/search/fetch_test.go (100%) rename {pkg/tools => tools}/search/register.go (95%) rename {pkg/tools => tools}/search/tavily.go (100%) rename {pkg/tools => tools}/search/tavily_test.go (100%) rename {pkg/tools => tools}/search/websearch.go (100%) rename {pkg/tools => tools}/search/websearch_tool.go (100%) rename {pkg/tools => tools}/spray/register.go (93%) rename {pkg/tools => tools}/spray/spray.go (98%) rename {pkg/tools => tools}/spray/spray_test.go (100%) rename {pkg/tools => tools}/toolargs/base.go (100%) rename {pkg/tools => tools}/toolargs/flags.go (100%) rename {pkg/tools => tools}/toolargs/help.go (100%) rename {pkg/tools => tools}/toolargs/normalize.go (100%) rename {pkg/tools => tools}/toolargs/resolve.go (100%) rename {pkg/tools => tools}/zombie/register.go (92%) rename {pkg/tools => tools}/zombie/zombie.go (97%) rename {pkg/tools => tools}/zombie/zombie_test.go (100%) diff --git a/cmd/agent/imports.go b/cmd/agent/imports.go index 3cd79600..5cb8d167 100644 --- a/cmd/agent/imports.go +++ b/cmd/agent/imports.go @@ -1,3 +1,3 @@ package main -import _ "github.com/chainreactors/aiscan/pkg/tools/arsenal" +import _ "github.com/chainreactors/aiscan/tools/arsenal" diff --git a/cmd/aiscan/imports.go b/cmd/aiscan/imports.go index ac30b23f..73cd56db 100644 --- a/cmd/aiscan/imports.go +++ b/cmd/aiscan/imports.go @@ -4,14 +4,14 @@ package main // Each package has a register.go that calls command.RegisterFactory(). import ( - _ "github.com/chainreactors/aiscan/pkg/tools" - _ "github.com/chainreactors/aiscan/pkg/tools/arsenal" - _ "github.com/chainreactors/aiscan/pkg/tools/gogo" - _ "github.com/chainreactors/aiscan/pkg/tools/ioa" - _ "github.com/chainreactors/aiscan/pkg/tools/neutron" - _ "github.com/chainreactors/aiscan/pkg/tools/proton" - _ "github.com/chainreactors/aiscan/pkg/tools/proxy" - _ "github.com/chainreactors/aiscan/pkg/tools/search" - _ "github.com/chainreactors/aiscan/pkg/tools/spray" - _ "github.com/chainreactors/aiscan/pkg/tools/zombie" + _ "github.com/chainreactors/aiscan/tools" + _ "github.com/chainreactors/aiscan/tools/arsenal" + _ "github.com/chainreactors/aiscan/tools/gogo" + _ "github.com/chainreactors/aiscan/tools/ioa" + _ "github.com/chainreactors/aiscan/tools/neutron" + _ "github.com/chainreactors/aiscan/tools/proton" + _ "github.com/chainreactors/aiscan/tools/proxy" + _ "github.com/chainreactors/aiscan/tools/search" + _ "github.com/chainreactors/aiscan/tools/spray" + _ "github.com/chainreactors/aiscan/tools/zombie" ) diff --git a/cmd/aiscan/imports_full.go b/cmd/aiscan/imports_full.go index 3bd0b7b8..cdf24a34 100644 --- a/cmd/aiscan/imports_full.go +++ b/cmd/aiscan/imports_full.go @@ -3,7 +3,7 @@ package main import ( - _ "github.com/chainreactors/aiscan/pkg/tools/katana" - _ "github.com/chainreactors/aiscan/pkg/tools/passive" - _ "github.com/chainreactors/aiscan/pkg/tools/playwright" + _ "github.com/chainreactors/aiscan/tools/katana" + _ "github.com/chainreactors/aiscan/tools/passive" + _ "github.com/chainreactors/aiscan/tools/playwright" ) diff --git a/cmd/aiscan/setup.go b/cmd/aiscan/setup.go index 7bb65080..87f16108 100644 --- a/cmd/aiscan/setup.go +++ b/cmd/aiscan/setup.go @@ -17,10 +17,10 @@ import ( "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/scan" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" "github.com/chainreactors/aiscan/pkg/tui" "github.com/chainreactors/aiscan/skills" + "github.com/chainreactors/aiscan/tools/scan" + "github.com/chainreactors/aiscan/tools/scan/engine" ioaclient "github.com/chainreactors/ioa/client" "github.com/chainreactors/ioa/protocols" ioaserver "github.com/chainreactors/ioa/server" diff --git a/cmd/runner/imports.go b/cmd/runner/imports.go index cba2dca8..6a000c8d 100644 --- a/cmd/runner/imports.go +++ b/cmd/runner/imports.go @@ -1,11 +1,11 @@ package main import ( - _ "github.com/chainreactors/aiscan/pkg/tools" - _ "github.com/chainreactors/aiscan/pkg/tools/arsenal" - _ "github.com/chainreactors/aiscan/pkg/tools/gogo" - _ "github.com/chainreactors/aiscan/pkg/tools/neutron" - _ "github.com/chainreactors/aiscan/pkg/tools/proton" - _ "github.com/chainreactors/aiscan/pkg/tools/spray" - _ "github.com/chainreactors/aiscan/pkg/tools/zombie" + _ "github.com/chainreactors/aiscan/tools" + _ "github.com/chainreactors/aiscan/tools/arsenal" + _ "github.com/chainreactors/aiscan/tools/gogo" + _ "github.com/chainreactors/aiscan/tools/neutron" + _ "github.com/chainreactors/aiscan/tools/proton" + _ "github.com/chainreactors/aiscan/tools/spray" + _ "github.com/chainreactors/aiscan/tools/zombie" ) diff --git a/cmd/runner/main.go b/cmd/runner/main.go index 48dd2941..0a5d4293 100644 --- a/cmd/runner/main.go +++ b/cmd/runner/main.go @@ -15,8 +15,8 @@ import ( "github.com/chainreactors/aiscan/core/resources" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" "github.com/chainreactors/aiscan/pkg/webagent" + "github.com/chainreactors/aiscan/tools/scan/engine" ) func main() { diff --git a/core/config/scanner_katana.go b/core/config/scanner_katana.go index ee35992d..8870151a 100644 --- a/core/config/scanner_katana.go +++ b/core/config/scanner_katana.go @@ -2,7 +2,7 @@ package config -import katanacmd "github.com/chainreactors/aiscan/pkg/tools/katana" +import katanacmd "github.com/chainreactors/aiscan/tools/katana" func init() { ExtraCommands["katana"] = true diff --git a/core/runner/runner.go b/core/runner/runner.go index 5d1dda03..a9ac8c3c 100644 --- a/core/runner/runner.go +++ b/core/runner/runner.go @@ -18,9 +18,9 @@ import ( "github.com/chainreactors/aiscan/pkg/aop" cmdpkg "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/toolargs" "github.com/chainreactors/aiscan/pkg/tui" "github.com/chainreactors/aiscan/skills" + "github.com/chainreactors/aiscan/tools/toolargs" ioaclient "github.com/chainreactors/ioa/client" "github.com/chainreactors/ioa/protocols" ) diff --git a/docs/development.md b/docs/development.md index 49b61d5e..2ed17424 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,5 +1,7 @@ # Aiscan 扩展开发手册 +> 新增结构化原生工具时,优先阅读 [`tools/README.md`](../tools/README.md)。最小实现只需要 `core/tool.Tool` 的四个方法和一次 `RegisterTool`;本页主要说明需要 Bash、扫描引擎或其他 Runtime 依赖的 Pseudo-Command/Factory 路径。 + Aiscan 提供两种对 AI 零侵入的扩展机制,开发者无需修改 agent 核心代码即可为 AI 增加新能力: | 扩展方式 | 实现方式 | 侵入程度 | 适用场景 | @@ -155,11 +157,11 @@ type Deps struct { ```go // cmd/aiscan/imports.go import ( - _ "github.com/chainreactors/aiscan/pkg/tools" // scanner 组 - _ "github.com/chainreactors/aiscan/pkg/tools/arsenal" // arsenal 组 - _ "github.com/chainreactors/aiscan/pkg/tools/ioa" // ioa 组 - _ "github.com/chainreactors/aiscan/pkg/tools/proxy" // proxy 组 - _ "github.com/chainreactors/aiscan/pkg/tools/search" // search 组 + _ "github.com/chainreactors/aiscan/tools" // scanner 组 + _ "github.com/chainreactors/aiscan/tools/arsenal" // arsenal 组 + _ "github.com/chainreactors/aiscan/tools/ioa" // ioa 组 + _ "github.com/chainreactors/aiscan/tools/proxy" // proxy 组 + _ "github.com/chainreactors/aiscan/tools/search" // search 组 ) ``` @@ -170,12 +172,12 @@ import ( **步骤 1:创建包目录** ``` -pkg/tools/whatweb/ +tools/whatweb/ ├── whatweb.go # 命令实现 └── register.go # 工厂注册 ``` -**步骤 2:实现 Command 接口** — `pkg/tools/whatweb/whatweb.go` +**步骤 2:实现 Command 接口** — `tools/whatweb/whatweb.go` ```go package whatweb @@ -259,7 +261,7 @@ func (c *Command) Execute(ctx context.Context, args []string) error { } ``` -**步骤 3:注册工厂** — `pkg/tools/whatweb/register.go` +**步骤 3:注册工厂** — `tools/whatweb/register.go` ```go package whatweb @@ -289,7 +291,7 @@ func init() { ```go import ( // ...existing imports... - _ "github.com/chainreactors/aiscan/pkg/tools/whatweb" + _ "github.com/chainreactors/aiscan/tools/whatweb" ) ``` diff --git a/pkg/agent/probe/conn.go b/pkg/agent/probe/conn.go index 9f17a76b..e941f34c 100644 --- a/pkg/agent/probe/conn.go +++ b/pkg/agent/probe/conn.go @@ -19,7 +19,7 @@ import ( ioaclient "github.com/chainreactors/ioa/client" "github.com/chainreactors/sdk/pkg/cyberhub" - "github.com/chainreactors/aiscan/pkg/tools/search" + "github.com/chainreactors/aiscan/tools/search" ) // ConnCheck is the outcome of probing one external dependency. A single diff --git a/pkg/web/service.go b/pkg/web/service.go index ef1fb211..368e25fc 100644 --- a/pkg/web/service.go +++ b/pkg/web/service.go @@ -22,9 +22,9 @@ import ( xcompact "github.com/chainreactors/aiscan/pkg/aop/x/compact" xeval "github.com/chainreactors/aiscan/pkg/aop/x/eval" "github.com/chainreactors/aiscan/pkg/commands" - scantool "github.com/chainreactors/aiscan/pkg/tools/scan" "github.com/chainreactors/aiscan/pkg/tui" "github.com/chainreactors/aiscan/pkg/webproto" + scantool "github.com/chainreactors/aiscan/tools/scan" ) // hubCommands are the 3 commands that run on the web hub, not the agent. diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 00000000..af871aba --- /dev/null +++ b/tools/README.md @@ -0,0 +1,102 @@ +# Extending aiscan with a tool + +Use a native tool when the model needs one structured capability. A tool only +implements `core/tool.Tool`; it does not need a plugin manager, lifecycle +container, global hook, or factory. + +## Minimal implementation + +Create `tools/echo/echo.go`: + +```go +package echo + +import ( + "context" + + "github.com/chainreactors/aiscan/core/tool" + "github.com/chainreactors/aiscan/pkg/commands" +) + +type Args struct { + Text string `json:"text" jsonschema:"description=Text to return"` +} + +type Tool struct{} + +func (Tool) Name() string { return "echo" } +func (Tool) Description() string { return "Return text unchanged." } +func (Tool) Definition() tool.Definition { + return tool.Def("echo", "Return text unchanged.", Args{}) +} + +func (Tool) Execute(ctx context.Context, arguments string) (tool.Result, error) { + args, err := tool.ParseArgs[Args](arguments) + if err != nil { + return tool.Result{}, err + } + if err := ctx.Err(); err != nil { + return tool.Result{}, err + } + return tool.TextResult(args.Text), nil +} + +func Register(reg *commands.CommandRegistry) { + reg.RegisterTool(Tool{}) +} +``` + +Call `echo.Register(reg)` from the application composition point that should +expose the tool. Keep registration explicit when the tool has no Runtime +dependencies. + +## Minimal test + +```go +package echo + +import ( + "context" + "testing" + + "github.com/chainreactors/aiscan/pkg/commands" +) + +func TestEcho(t *testing.T) { + reg := commands.NewRegistry() + Register(reg) + + result, err := reg.ExecuteTool(context.Background(), "echo", `{"text":"hello"}`) + if err != nil { + t.Fatal(err) + } + if result.Text() != "hello" { + t.Fatalf("result = %q", result.Text()) + } +} +``` + +This test proves schema registration, argument decoding, dispatch, and result +conversion without starting an Agent or transport. + +## Result rules + +- Use `tool.TextResult` for normal text. +- Use `tool.ErrorResult` for a tool-level failure the model should observe. +- Return a Go `error` when execution itself failed. +- Add `tool.ImageBlock` only when the result contains an image. +- Put machine-readable domain output in `Result.Details`; do not encode it into + an extra transport payload. +- Honor `ctx` for cancellation and deadlines. + +## When a factory is justified + +Use `commands.RegisterFactory` only when construction needs shared Runtime +dependencies such as the scanner engine set, IOA client, provider, data bus, or +working directory, or when an `init` registration must be activated by several +binaries. The factory should only construct the tool and call `RegisterTool`. + +Do not add a new abstraction until at least two tools need the same behavior. + +Pseudo-commands exposed through the `bash` tool are documented separately in +[`docs/development.md`](../docs/development.md). diff --git a/pkg/tools/arsenal/arsenal_tool.go b/tools/arsenal/arsenal_tool.go similarity index 100% rename from pkg/tools/arsenal/arsenal_tool.go rename to tools/arsenal/arsenal_tool.go diff --git a/pkg/tools/arsenal/arsenal_tool_test.go b/tools/arsenal/arsenal_tool_test.go similarity index 100% rename from pkg/tools/arsenal/arsenal_tool_test.go rename to tools/arsenal/arsenal_tool_test.go diff --git a/pkg/tools/arsenal/register.go b/tools/arsenal/register.go similarity index 100% rename from pkg/tools/arsenal/register.go rename to tools/arsenal/register.go diff --git a/pkg/tools/functional_integration_full_test.go b/tools/functional_integration_full_test.go similarity index 92% rename from pkg/tools/functional_integration_full_test.go rename to tools/functional_integration_full_test.go index 6cd0e694..e84ab212 100644 --- a/pkg/tools/functional_integration_full_test.go +++ b/tools/functional_integration_full_test.go @@ -13,8 +13,8 @@ import ( "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - _ "github.com/chainreactors/aiscan/pkg/tools/katana" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + _ "github.com/chainreactors/aiscan/tools/katana" + "github.com/chainreactors/aiscan/tools/scan/engine" ) func TestFullScannerPublicIntegration(t *testing.T) { diff --git a/pkg/tools/functional_integration_test.go b/tools/functional_integration_test.go similarity index 94% rename from pkg/tools/functional_integration_test.go rename to tools/functional_integration_test.go index 19556641..3a415a16 100644 --- a/pkg/tools/functional_integration_test.go +++ b/tools/functional_integration_test.go @@ -15,10 +15,10 @@ import ( "github.com/chainreactors/aiscan/core/resources" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - _ "github.com/chainreactors/aiscan/pkg/tools/gogo" - _ "github.com/chainreactors/aiscan/pkg/tools/neutron" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" - _ "github.com/chainreactors/aiscan/pkg/tools/spray" + _ "github.com/chainreactors/aiscan/tools/gogo" + _ "github.com/chainreactors/aiscan/tools/neutron" + "github.com/chainreactors/aiscan/tools/scan/engine" + _ "github.com/chainreactors/aiscan/tools/spray" "github.com/chainreactors/utils/parsers" ) diff --git a/pkg/tools/functional_norace_test.go b/tools/functional_norace_test.go similarity index 100% rename from pkg/tools/functional_norace_test.go rename to tools/functional_norace_test.go diff --git a/pkg/tools/functional_race_test.go b/tools/functional_race_test.go similarity index 100% rename from pkg/tools/functional_race_test.go rename to tools/functional_race_test.go diff --git a/pkg/tools/functional_regression_full_test.go b/tools/functional_regression_full_test.go similarity index 94% rename from pkg/tools/functional_regression_full_test.go rename to tools/functional_regression_full_test.go index 5c4aba66..d1457a96 100644 --- a/pkg/tools/functional_regression_full_test.go +++ b/tools/functional_regression_full_test.go @@ -13,9 +13,9 @@ import ( "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - _ "github.com/chainreactors/aiscan/pkg/tools/katana" - passivecmd "github.com/chainreactors/aiscan/pkg/tools/passive" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + _ "github.com/chainreactors/aiscan/tools/katana" + passivecmd "github.com/chainreactors/aiscan/tools/passive" + "github.com/chainreactors/aiscan/tools/scan/engine" "github.com/projectdiscovery/uncover/sources" ) diff --git a/pkg/tools/functional_regression_test.go b/tools/functional_regression_test.go similarity index 97% rename from pkg/tools/functional_regression_test.go rename to tools/functional_regression_test.go index 2a9fbc0d..3547584c 100644 --- a/pkg/tools/functional_regression_test.go +++ b/tools/functional_regression_test.go @@ -21,12 +21,12 @@ import ( "github.com/chainreactors/aiscan/core/resources" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - _ "github.com/chainreactors/aiscan/pkg/tools/gogo" - _ "github.com/chainreactors/aiscan/pkg/tools/neutron" - _ "github.com/chainreactors/aiscan/pkg/tools/proton" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" - _ "github.com/chainreactors/aiscan/pkg/tools/spray" - _ "github.com/chainreactors/aiscan/pkg/tools/zombie" + _ "github.com/chainreactors/aiscan/tools/gogo" + _ "github.com/chainreactors/aiscan/tools/neutron" + _ "github.com/chainreactors/aiscan/tools/proton" + "github.com/chainreactors/aiscan/tools/scan/engine" + _ "github.com/chainreactors/aiscan/tools/spray" + _ "github.com/chainreactors/aiscan/tools/zombie" "github.com/chainreactors/utils/parsers" ) diff --git a/pkg/tools/functional_testkit_test.go b/tools/functional_testkit_test.go similarity index 100% rename from pkg/tools/functional_testkit_test.go rename to tools/functional_testkit_test.go diff --git a/pkg/tools/gogo/gogo.go b/tools/gogo/gogo.go similarity index 98% rename from pkg/tools/gogo/gogo.go rename to tools/gogo/gogo.go index 5687ab39..71b42381 100644 --- a/pkg/tools/gogo/gogo.go +++ b/tools/gogo/gogo.go @@ -11,7 +11,7 @@ import ( "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/toolargs" + "github.com/chainreactors/aiscan/tools/toolargs" gogocore "github.com/chainreactors/gogo/v2/core" "github.com/chainreactors/sdk/gogo" "github.com/chainreactors/utils/parsers" diff --git a/pkg/tools/gogo/gogo_test.go b/tools/gogo/gogo_test.go similarity index 100% rename from pkg/tools/gogo/gogo_test.go rename to tools/gogo/gogo_test.go diff --git a/pkg/tools/gogo/register.go b/tools/gogo/register.go similarity index 93% rename from pkg/tools/gogo/register.go rename to tools/gogo/register.go index afebce61..65eb87a7 100644 --- a/pkg/tools/gogo/register.go +++ b/tools/gogo/register.go @@ -3,7 +3,7 @@ package gogo import ( cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/engine" ) func init() { diff --git a/pkg/tools/ioa/commands.go b/tools/ioa/commands.go similarity index 100% rename from pkg/tools/ioa/commands.go rename to tools/ioa/commands.go diff --git a/pkg/tools/ioa/commands_test.go b/tools/ioa/commands_test.go similarity index 99% rename from pkg/tools/ioa/commands_test.go rename to tools/ioa/commands_test.go index 5c9ab8df..b027edb0 100644 --- a/pkg/tools/ioa/commands_test.go +++ b/tools/ioa/commands_test.go @@ -489,7 +489,7 @@ func TestDefaultSpaceSkipsJoin(t *testing.T) { // Run with: // // LIVE_TEST_API_KEY=sk-xxx \ -// go test -v -run TestLLMIOAToolUsage ./pkg/tools/ioa/ -timeout 120s +// go test -v -run TestLLMIOAToolUsage ./tools/ioa/ -timeout 120s func TestLLMIOAToolUsage(t *testing.T) { apiKey := os.Getenv("LIVE_TEST_API_KEY") if apiKey == "" { diff --git a/pkg/tools/ioa/register.go b/tools/ioa/register.go similarity index 100% rename from pkg/tools/ioa/register.go rename to tools/ioa/register.go diff --git a/pkg/tools/katana/katana.go b/tools/katana/katana.go similarity index 99% rename from pkg/tools/katana/katana.go rename to tools/katana/katana.go index cf66e080..e52d2e06 100644 --- a/pkg/tools/katana/katana.go +++ b/tools/katana/katana.go @@ -15,7 +15,7 @@ import ( "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/toolargs" + "github.com/chainreactors/aiscan/tools/toolargs" "github.com/projectdiscovery/goflags" "github.com/projectdiscovery/gologger" "github.com/projectdiscovery/gologger/levels" diff --git a/pkg/tools/katana/register.go b/tools/katana/register.go similarity index 100% rename from pkg/tools/katana/register.go rename to tools/katana/register.go diff --git a/pkg/tools/neutron/neutron.go b/tools/neutron/neutron.go similarity index 99% rename from pkg/tools/neutron/neutron.go rename to tools/neutron/neutron.go index ad0632d4..ca0ac53e 100644 --- a/pkg/tools/neutron/neutron.go +++ b/tools/neutron/neutron.go @@ -16,8 +16,8 @@ import ( "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - scanengine "github.com/chainreactors/aiscan/pkg/tools/scan/engine" - "github.com/chainreactors/aiscan/pkg/tools/toolargs" + scanengine "github.com/chainreactors/aiscan/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/toolargs" "github.com/chainreactors/neutron/templates" sdkneutron "github.com/chainreactors/sdk/neutron" "github.com/chainreactors/sdk/pkg/association" diff --git a/pkg/tools/neutron/neutron_test.go b/tools/neutron/neutron_test.go similarity index 100% rename from pkg/tools/neutron/neutron_test.go rename to tools/neutron/neutron_test.go diff --git a/pkg/tools/neutron/register.go b/tools/neutron/register.go similarity index 93% rename from pkg/tools/neutron/register.go rename to tools/neutron/register.go index 9ba4251e..9a83bc4a 100644 --- a/pkg/tools/neutron/register.go +++ b/tools/neutron/register.go @@ -3,7 +3,7 @@ package neutron import ( cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/engine" ) func init() { diff --git a/pkg/tools/neutron/sdk_stage.go b/tools/neutron/sdk_stage.go similarity index 99% rename from pkg/tools/neutron/sdk_stage.go rename to tools/neutron/sdk_stage.go index e21685d0..e9832d44 100644 --- a/pkg/tools/neutron/sdk_stage.go +++ b/tools/neutron/sdk_stage.go @@ -9,7 +9,7 @@ import ( "time" "github.com/chainreactors/aiscan/pkg/telemetry" - scanengine "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + scanengine "github.com/chainreactors/aiscan/tools/scan/engine" "github.com/chainreactors/neutron/common" "github.com/chainreactors/neutron/templates" sdkneutron "github.com/chainreactors/sdk/neutron" diff --git a/pkg/tools/passive/passive.go b/tools/passive/passive.go similarity index 99% rename from pkg/tools/passive/passive.go rename to tools/passive/passive.go index dc503f28..6a1f5f73 100644 --- a/pkg/tools/passive/passive.go +++ b/tools/passive/passive.go @@ -16,7 +16,7 @@ import ( "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/engine" "github.com/projectdiscovery/uncover/sources" ) diff --git a/pkg/tools/passive/passive_test.go b/tools/passive/passive_test.go similarity index 98% rename from pkg/tools/passive/passive_test.go rename to tools/passive/passive_test.go index b4726f76..be2df63f 100644 --- a/pkg/tools/passive/passive_test.go +++ b/tools/passive/passive_test.go @@ -7,7 +7,7 @@ import ( "reflect" "testing" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/engine" "github.com/projectdiscovery/uncover/sources" ) diff --git a/pkg/tools/passive/register.go b/tools/passive/register.go similarity index 94% rename from pkg/tools/passive/register.go rename to tools/passive/register.go index ecdc26a7..e4b6f275 100644 --- a/pkg/tools/passive/register.go +++ b/tools/passive/register.go @@ -5,7 +5,7 @@ package passive import ( "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/engine" ) func init() { diff --git a/pkg/tools/playwright/advanced.go b/tools/playwright/advanced.go similarity index 100% rename from pkg/tools/playwright/advanced.go rename to tools/playwright/advanced.go diff --git a/pkg/tools/playwright/autofill.go b/tools/playwright/autofill.go similarity index 100% rename from pkg/tools/playwright/autofill.go rename to tools/playwright/autofill.go diff --git a/pkg/tools/playwright/browser.go b/tools/playwright/browser.go similarity index 100% rename from pkg/tools/playwright/browser.go rename to tools/playwright/browser.go diff --git a/pkg/tools/playwright/browser_test.go b/tools/playwright/browser_test.go similarity index 100% rename from pkg/tools/playwright/browser_test.go rename to tools/playwright/browser_test.go diff --git a/pkg/tools/playwright/dialog.go b/tools/playwright/dialog.go similarity index 100% rename from pkg/tools/playwright/dialog.go rename to tools/playwright/dialog.go diff --git a/pkg/tools/playwright/discover.go b/tools/playwright/discover.go similarity index 100% rename from pkg/tools/playwright/discover.go rename to tools/playwright/discover.go diff --git a/pkg/tools/playwright/headless.go b/tools/playwright/headless.go similarity index 100% rename from pkg/tools/playwright/headless.go rename to tools/playwright/headless.go diff --git a/pkg/tools/playwright/interact.go b/tools/playwright/interact.go similarity index 100% rename from pkg/tools/playwright/interact.go rename to tools/playwright/interact.go diff --git a/pkg/tools/playwright/navigation.go b/tools/playwright/navigation.go similarity index 100% rename from pkg/tools/playwright/navigation.go rename to tools/playwright/navigation.go diff --git a/pkg/tools/playwright/recorder.go b/tools/playwright/recorder.go similarity index 100% rename from pkg/tools/playwright/recorder.go rename to tools/playwright/recorder.go diff --git a/pkg/tools/playwright/recorder_test.go b/tools/playwright/recorder_test.go similarity index 100% rename from pkg/tools/playwright/recorder_test.go rename to tools/playwright/recorder_test.go diff --git a/pkg/tools/playwright/register.go b/tools/playwright/register.go similarity index 100% rename from pkg/tools/playwright/register.go rename to tools/playwright/register.go diff --git a/pkg/tools/playwright/session.go b/tools/playwright/session.go similarity index 100% rename from pkg/tools/playwright/session.go rename to tools/playwright/session.go diff --git a/pkg/tools/playwright/storage.go b/tools/playwright/storage.go similarity index 100% rename from pkg/tools/playwright/storage.go rename to tools/playwright/storage.go diff --git a/pkg/tools/playwright/storage_test.go b/tools/playwright/storage_test.go similarity index 100% rename from pkg/tools/playwright/storage_test.go rename to tools/playwright/storage_test.go diff --git a/pkg/tools/playwright/tabs.go b/tools/playwright/tabs.go similarity index 100% rename from pkg/tools/playwright/tabs.go rename to tools/playwright/tabs.go diff --git a/pkg/tools/playwright/testharness/.gitignore b/tools/playwright/testharness/.gitignore similarity index 100% rename from pkg/tools/playwright/testharness/.gitignore rename to tools/playwright/testharness/.gitignore diff --git a/pkg/tools/playwright/testharness/conftest.py b/tools/playwright/testharness/conftest.py similarity index 100% rename from pkg/tools/playwright/testharness/conftest.py rename to tools/playwright/testharness/conftest.py diff --git a/pkg/tools/playwright/testharness/fixtures/dynamic.html b/tools/playwright/testharness/fixtures/dynamic.html similarity index 100% rename from pkg/tools/playwright/testharness/fixtures/dynamic.html rename to tools/playwright/testharness/fixtures/dynamic.html diff --git a/pkg/tools/playwright/testharness/fixtures/forms.html b/tools/playwright/testharness/fixtures/forms.html similarity index 100% rename from pkg/tools/playwright/testharness/fixtures/forms.html rename to tools/playwright/testharness/fixtures/forms.html diff --git a/pkg/tools/playwright/testharness/fixtures/headless-extract.yaml b/tools/playwright/testharness/fixtures/headless-extract.yaml similarity index 100% rename from pkg/tools/playwright/testharness/fixtures/headless-extract.yaml rename to tools/playwright/testharness/fixtures/headless-extract.yaml diff --git a/pkg/tools/playwright/testharness/fixtures/headless-template.yaml b/tools/playwright/testharness/fixtures/headless-template.yaml similarity index 100% rename from pkg/tools/playwright/testharness/fixtures/headless-template.yaml rename to tools/playwright/testharness/fixtures/headless-template.yaml diff --git a/pkg/tools/playwright/testharness/fixtures/login.html b/tools/playwright/testharness/fixtures/login.html similarity index 100% rename from pkg/tools/playwright/testharness/fixtures/login.html rename to tools/playwright/testharness/fixtures/login.html diff --git a/pkg/tools/playwright/testharness/fixtures/navigation.html b/tools/playwright/testharness/fixtures/navigation.html similarity index 100% rename from pkg/tools/playwright/testharness/fixtures/navigation.html rename to tools/playwright/testharness/fixtures/navigation.html diff --git a/pkg/tools/playwright/testharness/fixtures/page2.html b/tools/playwright/testharness/fixtures/page2.html similarity index 100% rename from pkg/tools/playwright/testharness/fixtures/page2.html rename to tools/playwright/testharness/fixtures/page2.html diff --git a/pkg/tools/playwright/testharness/pw_driver.go b/tools/playwright/testharness/pw_driver.go similarity index 91% rename from pkg/tools/playwright/testharness/pw_driver.go rename to tools/playwright/testharness/pw_driver.go index 62fcc368..fc66e3f9 100644 --- a/pkg/tools/playwright/testharness/pw_driver.go +++ b/tools/playwright/testharness/pw_driver.go @@ -4,7 +4,7 @@ // It reads JSON-line commands from stdin and writes JSON-line responses to stdout. // The Command instance (and its sessions) persist across calls. // -// Build: go build -tags browser -o pw_driver ./pkg/tools/playwright/testharness/pw_driver.go +// Build: go build -tags browser -o pw_driver ./tools/playwright/testharness/pw_driver.go // // Protocol: // @@ -24,7 +24,7 @@ import ( "os/signal" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/tools/playwright" + "github.com/chainreactors/aiscan/tools/playwright" ) type request struct { diff --git a/pkg/tools/playwright/testharness/test_cli_parity.py b/tools/playwright/testharness/test_cli_parity.py similarity index 100% rename from pkg/tools/playwright/testharness/test_cli_parity.py rename to tools/playwright/testharness/test_cli_parity.py diff --git a/pkg/tools/playwright/testharness/test_dispatch.py b/tools/playwright/testharness/test_dispatch.py similarity index 100% rename from pkg/tools/playwright/testharness/test_dispatch.py rename to tools/playwright/testharness/test_dispatch.py diff --git a/pkg/tools/playwright/testharness/test_extraction.py b/tools/playwright/testharness/test_extraction.py similarity index 100% rename from pkg/tools/playwright/testharness/test_extraction.py rename to tools/playwright/testharness/test_extraction.py diff --git a/pkg/tools/playwright/testharness/test_files.py b/tools/playwright/testharness/test_files.py similarity index 100% rename from pkg/tools/playwright/testharness/test_files.py rename to tools/playwright/testharness/test_files.py diff --git a/pkg/tools/playwright/testharness/test_focus.py b/tools/playwright/testharness/test_focus.py similarity index 100% rename from pkg/tools/playwright/testharness/test_focus.py rename to tools/playwright/testharness/test_focus.py diff --git a/pkg/tools/playwright/testharness/test_headers.py b/tools/playwright/testharness/test_headers.py similarity index 100% rename from pkg/tools/playwright/testharness/test_headers.py rename to tools/playwright/testharness/test_headers.py diff --git a/pkg/tools/playwright/testharness/test_headless_template.py b/tools/playwright/testharness/test_headless_template.py similarity index 100% rename from pkg/tools/playwright/testharness/test_headless_template.py rename to tools/playwright/testharness/test_headless_template.py diff --git a/pkg/tools/playwright/testharness/test_interaction.py b/tools/playwright/testharness/test_interaction.py similarity index 100% rename from pkg/tools/playwright/testharness/test_interaction.py rename to tools/playwright/testharness/test_interaction.py diff --git a/pkg/tools/playwright/testharness/test_navigation.py b/tools/playwright/testharness/test_navigation.py similarity index 100% rename from pkg/tools/playwright/testharness/test_navigation.py rename to tools/playwright/testharness/test_navigation.py diff --git a/pkg/tools/playwright/testharness/test_route.py b/tools/playwright/testharness/test_route.py similarity index 100% rename from pkg/tools/playwright/testharness/test_route.py rename to tools/playwright/testharness/test_route.py diff --git a/pkg/tools/playwright/testharness/test_viewport.py b/tools/playwright/testharness/test_viewport.py similarity index 100% rename from pkg/tools/playwright/testharness/test_viewport.py rename to tools/playwright/testharness/test_viewport.py diff --git a/pkg/tools/playwright/testharness/test_wait.py b/tools/playwright/testharness/test_wait.py similarity index 100% rename from pkg/tools/playwright/testharness/test_wait.py rename to tools/playwright/testharness/test_wait.py diff --git a/pkg/tools/proton/command.go b/tools/proton/command.go similarity index 99% rename from pkg/tools/proton/command.go rename to tools/proton/command.go index 0ebe3612..af7adf37 100644 --- a/pkg/tools/proton/command.go +++ b/tools/proton/command.go @@ -19,7 +19,7 @@ import ( "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/toolargs" + "github.com/chainreactors/aiscan/tools/toolargs" "github.com/chainreactors/neutron/operators" "github.com/chainreactors/neutron/protocols" "github.com/chainreactors/proton/proton/file" diff --git a/pkg/tools/proton/command_test.go b/tools/proton/command_test.go similarity index 99% rename from pkg/tools/proton/command_test.go rename to tools/proton/command_test.go index 3ad30f11..090df25d 100644 --- a/pkg/tools/proton/command_test.go +++ b/tools/proton/command_test.go @@ -11,7 +11,7 @@ import ( "github.com/chainreactors/aiscan/core/resources" "github.com/chainreactors/aiscan/pkg/commands" - protoncmd "github.com/chainreactors/aiscan/pkg/tools/proton" + protoncmd "github.com/chainreactors/aiscan/tools/proton" ) // --------------------------------------------------------------------------- diff --git a/pkg/tools/proton/register.go b/tools/proton/register.go similarity index 100% rename from pkg/tools/proton/register.go rename to tools/proton/register.go diff --git a/pkg/tools/proton/register_test.go b/tools/proton/register_test.go similarity index 100% rename from pkg/tools/proton/register_test.go rename to tools/proton/register_test.go diff --git a/pkg/tools/proxy/command.go b/tools/proxy/command.go similarity index 100% rename from pkg/tools/proxy/command.go rename to tools/proxy/command.go diff --git a/pkg/tools/proxy/command_test.go b/tools/proxy/command_test.go similarity index 100% rename from pkg/tools/proxy/command_test.go rename to tools/proxy/command_test.go diff --git a/pkg/tools/proxy/mitm.go b/tools/proxy/mitm.go similarity index 100% rename from pkg/tools/proxy/mitm.go rename to tools/proxy/mitm.go diff --git a/pkg/tools/proxy/mitm_test.go b/tools/proxy/mitm_test.go similarity index 100% rename from pkg/tools/proxy/mitm_test.go rename to tools/proxy/mitm_test.go diff --git a/pkg/tools/proxy/race_norace_test.go b/tools/proxy/race_norace_test.go similarity index 100% rename from pkg/tools/proxy/race_norace_test.go rename to tools/proxy/race_norace_test.go diff --git a/pkg/tools/proxy/race_test.go b/tools/proxy/race_test.go similarity index 100% rename from pkg/tools/proxy/race_test.go rename to tools/proxy/race_test.go diff --git a/pkg/tools/proxy/register_command.go b/tools/proxy/register_command.go similarity index 100% rename from pkg/tools/proxy/register_command.go rename to tools/proxy/register_command.go diff --git a/pkg/tools/proxy/state.go b/tools/proxy/state.go similarity index 100% rename from pkg/tools/proxy/state.go rename to tools/proxy/state.go diff --git a/pkg/tools/proxy/state_test.go b/tools/proxy/state_test.go similarity index 100% rename from pkg/tools/proxy/state_test.go rename to tools/proxy/state_test.go diff --git a/pkg/tools/register_command.go b/tools/register_command.go similarity index 89% rename from pkg/tools/register_command.go rename to tools/register_command.go index 2507b4c4..33bf7f10 100644 --- a/pkg/tools/register_command.go +++ b/tools/register_command.go @@ -3,8 +3,8 @@ package tools import ( cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/tools/scan" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan" + "github.com/chainreactors/aiscan/tools/scan/engine" ) func init() { diff --git a/pkg/tools/register_command_full_test.go b/tools/register_command_full_test.go similarity index 93% rename from pkg/tools/register_command_full_test.go rename to tools/register_command_full_test.go index 9ab600c0..962e00ea 100644 --- a/pkg/tools/register_command_full_test.go +++ b/tools/register_command_full_test.go @@ -5,7 +5,7 @@ package tools import ( "testing" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/engine" "github.com/chainreactors/sdk/gogo" "github.com/chainreactors/sdk/spray" ) diff --git a/pkg/tools/register_command_integration_test.go b/tools/register_command_integration_test.go similarity index 93% rename from pkg/tools/register_command_integration_test.go rename to tools/register_command_integration_test.go index 88ac149f..4f31e33e 100644 --- a/pkg/tools/register_command_integration_test.go +++ b/tools/register_command_integration_test.go @@ -1,7 +1,7 @@ //go:build full && integration // Run with: AISCAN_INTEGRATION=1 FOFA_EMAIL=... FOFA_KEY=... \ -// go test -tags 'full integration' ./pkg/tools/... -run TestIntegration -v +// go test -tags 'full integration' ./tools/... -run TestIntegration -v package tools import ( @@ -15,8 +15,8 @@ import ( "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - passivecmd "github.com/chainreactors/aiscan/pkg/tools/passive" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + passivecmd "github.com/chainreactors/aiscan/tools/passive" + "github.com/chainreactors/aiscan/tools/scan/engine" ) func passiveExecString(t *testing.T, cmd *passivecmd.Command, ctx context.Context, args []string) string { diff --git a/pkg/tools/register_command_test.go b/tools/register_command_test.go similarity index 95% rename from pkg/tools/register_command_test.go rename to tools/register_command_test.go index bca2d107..5c6ad7d4 100644 --- a/pkg/tools/register_command_test.go +++ b/tools/register_command_test.go @@ -13,12 +13,12 @@ import ( "github.com/chainreactors/aiscan/core/resources" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/tools/gogo" - "github.com/chainreactors/aiscan/pkg/tools/neutron" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" - _ "github.com/chainreactors/aiscan/pkg/tools/search" - "github.com/chainreactors/aiscan/pkg/tools/spray" - "github.com/chainreactors/aiscan/pkg/tools/zombie" + "github.com/chainreactors/aiscan/tools/gogo" + "github.com/chainreactors/aiscan/tools/neutron" + "github.com/chainreactors/aiscan/tools/scan/engine" + _ "github.com/chainreactors/aiscan/tools/search" + "github.com/chainreactors/aiscan/tools/spray" + "github.com/chainreactors/aiscan/tools/zombie" fingerslib "github.com/chainreactors/fingers/fingers" neutronhttp "github.com/chainreactors/neutron/protocols/http" "github.com/chainreactors/proxyclient" diff --git a/pkg/tools/scan/adapter.go b/tools/scan/adapter.go similarity index 99% rename from pkg/tools/scan/adapter.go rename to tools/scan/adapter.go index 4abb30c6..c5a56a6a 100644 --- a/pkg/tools/scan/adapter.go +++ b/tools/scan/adapter.go @@ -6,11 +6,11 @@ import ( "net/url" "strings" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" - "github.com/chainreactors/utils/parsers" + "github.com/chainreactors/aiscan/tools/scan/engine" sdktypes "github.com/chainreactors/sdk/pkg/types" sdkzombie "github.com/chainreactors/sdk/zombie" "github.com/chainreactors/utils" + "github.com/chainreactors/utils/parsers" zombiepkg "github.com/chainreactors/zombie/pkg" ) diff --git a/pkg/tools/scan/aggregate.go b/tools/scan/aggregate.go similarity index 100% rename from pkg/tools/scan/aggregate.go rename to tools/scan/aggregate.go diff --git a/pkg/tools/scan/aggregate_test.go b/tools/scan/aggregate_test.go similarity index 100% rename from pkg/tools/scan/aggregate_test.go rename to tools/scan/aggregate_test.go diff --git a/pkg/tools/scan/bridge.go b/tools/scan/bridge.go similarity index 96% rename from pkg/tools/scan/bridge.go rename to tools/scan/bridge.go index b883b728..92b6a3bb 100644 --- a/pkg/tools/scan/bridge.go +++ b/tools/scan/bridge.go @@ -6,7 +6,7 @@ import ( "io" "github.com/chainreactors/aiscan/core/eventbus" - "github.com/chainreactors/aiscan/pkg/tools/scan/pipeline" + "github.com/chainreactors/aiscan/tools/scan/pipeline" ) type pipelineEvent struct { diff --git a/pkg/tools/scan/capability.go b/tools/scan/capability.go similarity index 98% rename from pkg/tools/scan/capability.go rename to tools/scan/capability.go index c188986d..f18f54e1 100644 --- a/pkg/tools/scan/capability.go +++ b/tools/scan/capability.go @@ -3,8 +3,8 @@ package scan import ( "context" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" - "github.com/chainreactors/aiscan/pkg/tools/scan/pipeline" + "github.com/chainreactors/aiscan/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/pipeline" ) const ( diff --git a/pkg/tools/scan/capability_katana.go b/tools/scan/capability_katana.go similarity index 87% rename from pkg/tools/scan/capability_katana.go rename to tools/scan/capability_katana.go index f6497b6f..74699eb0 100644 --- a/pkg/tools/scan/capability_katana.go +++ b/tools/scan/capability_katana.go @@ -16,7 +16,7 @@ import ( katanatypes "github.com/projectdiscovery/katana/pkg/types" "github.com/projectdiscovery/katana/pkg/utils/queue" - "github.com/chainreactors/aiscan/pkg/tools/scan/pipeline" + "github.com/chainreactors/aiscan/tools/scan/pipeline" ) const ( @@ -87,17 +87,17 @@ func runKatanaCrawl(ctx context.Context, c *Command, e event, depth int, jsMode seen := make(map[string]struct{}) options := &katanatypes.Options{ - MaxDepth: depth, - FieldScope: "rdn", - BodyReadSize: math.MaxInt, - RateLimit: 150, - Strategy: queue.DepthFirst.String(), - Silent: true, - ScrapeJSResponses: jsMode, + MaxDepth: depth, + FieldScope: "rdn", + BodyReadSize: math.MaxInt, + RateLimit: 150, + Strategy: queue.DepthFirst.String(), + Silent: true, + ScrapeJSResponses: jsMode, ScrapeJSLuiceResponses: jsMode, - Timeout: 10, - Concurrency: 10, - Parallelism: 10, + Timeout: 10, + Concurrency: 10, + Parallelism: 10, OnResult: func(r katanaoutput.Result) { if r.Request == nil || r.Request.URL == "" { return @@ -177,6 +177,6 @@ func sameRootDomain(rawURL, rdn string) bool { type silentWriter struct{} -func (w *silentWriter) Close() error { return nil } -func (w *silentWriter) Write(_ *katanaoutput.Result) error { return nil } +func (w *silentWriter) Close() error { return nil } +func (w *silentWriter) Write(_ *katanaoutput.Result) error { return nil } func (w *silentWriter) WriteErr(_ *katanaoutput.Error) error { return nil } diff --git a/pkg/tools/scan/capability_katana_stub.go b/tools/scan/capability_katana_stub.go similarity index 100% rename from pkg/tools/scan/capability_katana_stub.go rename to tools/scan/capability_katana_stub.go diff --git a/pkg/tools/scan/capability_katana_test.go b/tools/scan/capability_katana_test.go similarity index 100% rename from pkg/tools/scan/capability_katana_test.go rename to tools/scan/capability_katana_test.go diff --git a/pkg/tools/scan/collector.go b/tools/scan/collector.go similarity index 99% rename from pkg/tools/scan/collector.go rename to tools/scan/collector.go index 303692c4..f8854e9b 100644 --- a/pkg/tools/scan/collector.go +++ b/tools/scan/collector.go @@ -8,7 +8,7 @@ import ( "time" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/tools/scan/pipeline" + "github.com/chainreactors/aiscan/tools/scan/pipeline" sdktypes "github.com/chainreactors/sdk/pkg/types" "github.com/chainreactors/utils" "github.com/chainreactors/utils/parsers" diff --git a/pkg/tools/scan/command.go b/tools/scan/command.go similarity index 98% rename from pkg/tools/scan/command.go rename to tools/scan/command.go index 02a8837d..c405df38 100644 --- a/pkg/tools/scan/command.go +++ b/tools/scan/command.go @@ -13,9 +13,9 @@ import ( "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" - "github.com/chainreactors/aiscan/pkg/tools/scan/pipeline" - "github.com/chainreactors/aiscan/pkg/tools/toolargs" + "github.com/chainreactors/aiscan/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/pipeline" + "github.com/chainreactors/aiscan/tools/toolargs" goflags "github.com/jessevdk/go-flags" ) diff --git a/pkg/tools/scan/command_test.go b/tools/scan/command_test.go similarity index 99% rename from pkg/tools/scan/command_test.go rename to tools/scan/command_test.go index 548b6c51..5bf8967a 100644 --- a/pkg/tools/scan/command_test.go +++ b/tools/scan/command_test.go @@ -18,8 +18,8 @@ import ( "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" - "github.com/chainreactors/aiscan/pkg/tools/scan/pipeline" + "github.com/chainreactors/aiscan/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/pipeline" "github.com/chainreactors/fingers/common" "github.com/chainreactors/logs" "github.com/chainreactors/neutron/operators" diff --git a/pkg/tools/scan/data_bus_test.go b/tools/scan/data_bus_test.go similarity index 95% rename from pkg/tools/scan/data_bus_test.go rename to tools/scan/data_bus_test.go index 03903ed9..1a0b2e77 100644 --- a/pkg/tools/scan/data_bus_test.go +++ b/tools/scan/data_bus_test.go @@ -6,7 +6,7 @@ import ( "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/engine" "github.com/chainreactors/utils/parsers" ) diff --git a/pkg/tools/scan/engine/gogo.go b/tools/scan/engine/gogo.go similarity index 100% rename from pkg/tools/scan/engine/gogo.go rename to tools/scan/engine/gogo.go diff --git a/pkg/tools/scan/engine/gogo_test.go b/tools/scan/engine/gogo_test.go similarity index 100% rename from pkg/tools/scan/engine/gogo_test.go rename to tools/scan/engine/gogo_test.go diff --git a/pkg/tools/scan/engine/neutron.go b/tools/scan/engine/neutron.go similarity index 100% rename from pkg/tools/scan/engine/neutron.go rename to tools/scan/engine/neutron.go diff --git a/pkg/tools/scan/engine/race_norace_test.go b/tools/scan/engine/race_norace_test.go similarity index 100% rename from pkg/tools/scan/engine/race_norace_test.go rename to tools/scan/engine/race_norace_test.go diff --git a/pkg/tools/scan/engine/race_test.go b/tools/scan/engine/race_test.go similarity index 100% rename from pkg/tools/scan/engine/race_test.go rename to tools/scan/engine/race_test.go diff --git a/pkg/tools/scan/engine/set.go b/tools/scan/engine/set.go similarity index 100% rename from pkg/tools/scan/engine/set.go rename to tools/scan/engine/set.go diff --git a/pkg/tools/scan/engine/set_test.go b/tools/scan/engine/set_test.go similarity index 100% rename from pkg/tools/scan/engine/set_test.go rename to tools/scan/engine/set_test.go diff --git a/pkg/tools/scan/engine/set_uncover_recon.go b/tools/scan/engine/set_uncover_recon.go similarity index 100% rename from pkg/tools/scan/engine/set_uncover_recon.go rename to tools/scan/engine/set_uncover_recon.go diff --git a/pkg/tools/scan/engine/set_uncover_stub.go b/tools/scan/engine/set_uncover_stub.go similarity index 100% rename from pkg/tools/scan/engine/set_uncover_stub.go rename to tools/scan/engine/set_uncover_stub.go diff --git a/pkg/tools/scan/engine/spray.go b/tools/scan/engine/spray.go similarity index 100% rename from pkg/tools/scan/engine/spray.go rename to tools/scan/engine/spray.go diff --git a/pkg/tools/scan/engine/spray_test.go b/tools/scan/engine/spray_test.go similarity index 100% rename from pkg/tools/scan/engine/spray_test.go rename to tools/scan/engine/spray_test.go diff --git a/pkg/tools/scan/engine/uncover.go b/tools/scan/engine/uncover.go similarity index 100% rename from pkg/tools/scan/engine/uncover.go rename to tools/scan/engine/uncover.go diff --git a/pkg/tools/scan/engine/uncover_agents.go b/tools/scan/engine/uncover_agents.go similarity index 100% rename from pkg/tools/scan/engine/uncover_agents.go rename to tools/scan/engine/uncover_agents.go diff --git a/pkg/tools/scan/engine/uncover_stub.go b/tools/scan/engine/uncover_stub.go similarity index 100% rename from pkg/tools/scan/engine/uncover_stub.go rename to tools/scan/engine/uncover_stub.go diff --git a/pkg/tools/scan/engine/uncover_test.go b/tools/scan/engine/uncover_test.go similarity index 100% rename from pkg/tools/scan/engine/uncover_test.go rename to tools/scan/engine/uncover_test.go diff --git a/pkg/tools/scan/engine/zombie.go b/tools/scan/engine/zombie.go similarity index 100% rename from pkg/tools/scan/engine/zombie.go rename to tools/scan/engine/zombie.go diff --git a/pkg/tools/scan/event.go b/tools/scan/event.go similarity index 100% rename from pkg/tools/scan/event.go rename to tools/scan/event.go diff --git a/pkg/tools/scan/http_auth.go b/tools/scan/http_auth.go similarity index 100% rename from pkg/tools/scan/http_auth.go rename to tools/scan/http_auth.go diff --git a/pkg/tools/scan/input.go b/tools/scan/input.go similarity index 100% rename from pkg/tools/scan/input.go rename to tools/scan/input.go diff --git a/pkg/tools/scan/intent.go b/tools/scan/intent.go similarity index 100% rename from pkg/tools/scan/intent.go rename to tools/scan/intent.go diff --git a/pkg/tools/scan/jsonl_writer.go b/tools/scan/jsonl_writer.go similarity index 97% rename from pkg/tools/scan/jsonl_writer.go rename to tools/scan/jsonl_writer.go index b80c73ea..b634d555 100644 --- a/pkg/tools/scan/jsonl_writer.go +++ b/tools/scan/jsonl_writer.go @@ -7,7 +7,7 @@ import ( "github.com/chainreactors/aiscan/core/eventbus" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/aop" - "github.com/chainreactors/aiscan/pkg/tools/scan/pipeline" + "github.com/chainreactors/aiscan/tools/scan/pipeline" ) type scanJSONLWriter struct { diff --git a/pkg/tools/scan/options.go b/tools/scan/options.go similarity index 100% rename from pkg/tools/scan/options.go rename to tools/scan/options.go diff --git a/pkg/tools/scan/output.go b/tools/scan/output.go similarity index 100% rename from pkg/tools/scan/output.go rename to tools/scan/output.go diff --git a/pkg/tools/scan/pipeline/pipeline.go b/tools/scan/pipeline/pipeline.go similarity index 100% rename from pkg/tools/scan/pipeline/pipeline.go rename to tools/scan/pipeline/pipeline.go diff --git a/pkg/tools/scan/pipeline/pipeline_test.go b/tools/scan/pipeline/pipeline_test.go similarity index 100% rename from pkg/tools/scan/pipeline/pipeline_test.go rename to tools/scan/pipeline/pipeline_test.go diff --git a/pkg/tools/scan/report.go b/tools/scan/report.go similarity index 100% rename from pkg/tools/scan/report.go rename to tools/scan/report.go diff --git a/pkg/tools/scan/report_json.go b/tools/scan/report_json.go similarity index 100% rename from pkg/tools/scan/report_json.go rename to tools/scan/report_json.go diff --git a/pkg/tools/scan/report_plain.go b/tools/scan/report_plain.go similarity index 100% rename from pkg/tools/scan/report_plain.go rename to tools/scan/report_plain.go diff --git a/pkg/tools/scan/scan_options.go b/tools/scan/scan_options.go similarity index 100% rename from pkg/tools/scan/scan_options.go rename to tools/scan/scan_options.go diff --git a/pkg/tools/scan/sco.go b/tools/scan/sco.go similarity index 100% rename from pkg/tools/scan/sco.go rename to tools/scan/sco.go diff --git a/pkg/tools/scan/sco_stub.go b/tools/scan/sco_stub.go similarity index 100% rename from pkg/tools/scan/sco_stub.go rename to tools/scan/sco_stub.go diff --git a/pkg/tools/scan/sco_test.go b/tools/scan/sco_test.go similarity index 97% rename from pkg/tools/scan/sco_test.go rename to tools/scan/sco_test.go index 9518e1b1..82c63893 100644 --- a/pkg/tools/scan/sco_test.go +++ b/tools/scan/sco_test.go @@ -7,7 +7,7 @@ import ( "testing" "github.com/chainreactors/aiscan/core/eventbus" - "github.com/chainreactors/aiscan/pkg/tools/scan/pipeline" + "github.com/chainreactors/aiscan/tools/scan/pipeline" "github.com/chainreactors/utils/parsers" ) diff --git a/pkg/tools/scan/structured.go b/tools/scan/structured.go similarity index 100% rename from pkg/tools/scan/structured.go rename to tools/scan/structured.go diff --git a/pkg/tools/scan/target.go b/tools/scan/target.go similarity index 100% rename from pkg/tools/scan/target.go rename to tools/scan/target.go diff --git a/pkg/tools/scan/verify.go b/tools/scan/verify.go similarity index 100% rename from pkg/tools/scan/verify.go rename to tools/scan/verify.go diff --git a/pkg/tools/search/cyberhub.go b/tools/search/cyberhub.go similarity index 100% rename from pkg/tools/search/cyberhub.go rename to tools/search/cyberhub.go diff --git a/pkg/tools/search/cyberhub_test.go b/tools/search/cyberhub_test.go similarity index 100% rename from pkg/tools/search/cyberhub_test.go rename to tools/search/cyberhub_test.go diff --git a/pkg/tools/search/fetch.go b/tools/search/fetch.go similarity index 100% rename from pkg/tools/search/fetch.go rename to tools/search/fetch.go diff --git a/pkg/tools/search/fetch_test.go b/tools/search/fetch_test.go similarity index 100% rename from pkg/tools/search/fetch_test.go rename to tools/search/fetch_test.go diff --git a/pkg/tools/search/register.go b/tools/search/register.go similarity index 95% rename from pkg/tools/search/register.go rename to tools/search/register.go index 7c183290..5d9a4817 100644 --- a/pkg/tools/search/register.go +++ b/tools/search/register.go @@ -4,7 +4,7 @@ import ( "github.com/chainreactors/aiscan/core/resources" "github.com/chainreactors/aiscan/pkg/agent/provider" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/engine" "github.com/chainreactors/sdk/pkg/association" ) diff --git a/pkg/tools/search/tavily.go b/tools/search/tavily.go similarity index 100% rename from pkg/tools/search/tavily.go rename to tools/search/tavily.go diff --git a/pkg/tools/search/tavily_test.go b/tools/search/tavily_test.go similarity index 100% rename from pkg/tools/search/tavily_test.go rename to tools/search/tavily_test.go diff --git a/pkg/tools/search/websearch.go b/tools/search/websearch.go similarity index 100% rename from pkg/tools/search/websearch.go rename to tools/search/websearch.go diff --git a/pkg/tools/search/websearch_tool.go b/tools/search/websearch_tool.go similarity index 100% rename from pkg/tools/search/websearch_tool.go rename to tools/search/websearch_tool.go diff --git a/pkg/tools/spray/register.go b/tools/spray/register.go similarity index 93% rename from pkg/tools/spray/register.go rename to tools/spray/register.go index 4c0c14ac..3489fdd2 100644 --- a/pkg/tools/spray/register.go +++ b/tools/spray/register.go @@ -3,7 +3,7 @@ package spray import ( cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/engine" ) func init() { diff --git a/pkg/tools/spray/spray.go b/tools/spray/spray.go similarity index 98% rename from pkg/tools/spray/spray.go rename to tools/spray/spray.go index cf28ee5f..69e0035c 100644 --- a/pkg/tools/spray/spray.go +++ b/tools/spray/spray.go @@ -11,7 +11,7 @@ import ( "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/toolargs" + "github.com/chainreactors/aiscan/tools/toolargs" "github.com/chainreactors/sdk/spray" spraycore "github.com/chainreactors/spray/core" "github.com/chainreactors/utils/parsers" diff --git a/pkg/tools/spray/spray_test.go b/tools/spray/spray_test.go similarity index 100% rename from pkg/tools/spray/spray_test.go rename to tools/spray/spray_test.go diff --git a/pkg/tools/toolargs/base.go b/tools/toolargs/base.go similarity index 100% rename from pkg/tools/toolargs/base.go rename to tools/toolargs/base.go diff --git a/pkg/tools/toolargs/flags.go b/tools/toolargs/flags.go similarity index 100% rename from pkg/tools/toolargs/flags.go rename to tools/toolargs/flags.go diff --git a/pkg/tools/toolargs/help.go b/tools/toolargs/help.go similarity index 100% rename from pkg/tools/toolargs/help.go rename to tools/toolargs/help.go diff --git a/pkg/tools/toolargs/normalize.go b/tools/toolargs/normalize.go similarity index 100% rename from pkg/tools/toolargs/normalize.go rename to tools/toolargs/normalize.go diff --git a/pkg/tools/toolargs/resolve.go b/tools/toolargs/resolve.go similarity index 100% rename from pkg/tools/toolargs/resolve.go rename to tools/toolargs/resolve.go diff --git a/pkg/tools/zombie/register.go b/tools/zombie/register.go similarity index 92% rename from pkg/tools/zombie/register.go rename to tools/zombie/register.go index 722d7023..8b78f30b 100644 --- a/pkg/tools/zombie/register.go +++ b/tools/zombie/register.go @@ -3,7 +3,7 @@ package zombie import ( cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" + "github.com/chainreactors/aiscan/tools/scan/engine" ) func init() { diff --git a/pkg/tools/zombie/zombie.go b/tools/zombie/zombie.go similarity index 97% rename from pkg/tools/zombie/zombie.go rename to tools/zombie/zombie.go index 5975a5a0..6d3e0966 100644 --- a/pkg/tools/zombie/zombie.go +++ b/tools/zombie/zombie.go @@ -10,7 +10,7 @@ import ( "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/toolargs" + "github.com/chainreactors/aiscan/tools/toolargs" sdkzombie "github.com/chainreactors/sdk/zombie" zombiecore "github.com/chainreactors/zombie/core" ) diff --git a/pkg/tools/zombie/zombie_test.go b/tools/zombie/zombie_test.go similarity index 100% rename from pkg/tools/zombie/zombie_test.go rename to tools/zombie/zombie_test.go From 9fc27e5b9753480767b60146d47b569200d513b3 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Sat, 25 Jul 2026 13:20:14 +0800 Subject: [PATCH 7/9] refactor(commands): remove TUI from runtime command execution --- core/output/timeline.go | 16 ++- core/output/timeline_test.go | 15 +++ core/runner/runtime_protocol.go | 12 +- core/runner/runtime_semantics_test.go | 5 + core/runner/runtime_session.go | 128 +++++++++++++++++----- pkg/aop/x/command/command.go | 13 +++ pkg/tui/commands.go | 2 +- pkg/tui/console.go | 20 ++++ pkg/webagent/agent.go | 34 +----- pkg/webagent/agent_test.go | 25 ----- pkg/webproto/message.go | 6 - web/frontend/src/components/ChatPanel.tsx | 26 ++++- 12 files changed, 203 insertions(+), 99 deletions(-) create mode 100644 pkg/aop/x/command/command.go diff --git a/core/output/timeline.go b/core/output/timeline.go index 6280956a..22b0ba77 100644 --- a/core/output/timeline.go +++ b/core/output/timeline.go @@ -11,6 +11,7 @@ import ( "time" "github.com/chainreactors/aiscan/pkg/aop" + xcommand "github.com/chainreactors/aiscan/pkg/aop/x/command" "github.com/chainreactors/utils/parsers" "github.com/charmbracelet/glamour" "github.com/muesli/termenv" @@ -377,7 +378,12 @@ func writeAOPMarkdown(sb *strings.Builder, event *aop.Event) { if data.Role == "user" { sb.WriteString(fmt.Sprintf("> %s\n\n", TruncateStr(text, 200))) } else { - sb.WriteString(text + "\n\n") + detail, ok, _ := xcommand.GetDetail(*event) + if ok && detail.Presentation == "preformatted" { + sb.WriteString(markdownCodeFence(text) + "\n\n") + } else { + sb.WriteString(text + "\n\n") + } } case aop.TypeToolCall: @@ -445,3 +451,11 @@ func writeAOPMarkdown(sb *strings.Builder, event *aop.Event) { } } } + +func markdownCodeFence(text string) string { + fence := "```" + for strings.Contains(text, fence) { + fence += "`" + } + return fence + "\n" + text + "\n" + fence +} diff --git a/core/output/timeline_test.go b/core/output/timeline_test.go index 342643bd..baac2462 100644 --- a/core/output/timeline_test.go +++ b/core/output/timeline_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/chainreactors/aiscan/pkg/aop" + xcommand "github.com/chainreactors/aiscan/pkg/aop/x/command" ) func TestParseLineReadsNativeAOPEnvelope(t *testing.T) { @@ -38,6 +39,20 @@ func TestTimelineRendersStructuredToolResult(t *testing.T) { } } +func TestTimelineFormatsPreformattedCommandAtPresentationBoundary(t *testing.T) { + data, _ := json.Marshal(aop.MessageData{ + MessageID: "command-1", Role: "assistant", Parts: []aop.MessagePart{{Type: aop.PartText, Text: "one\ntwo"}}, + }) + event := aop.Event{ + Type: aop.TypeMessage, TS: "2026-07-20T00:00:00Z", SessionID: "session-1", Agent: "aiscan", Data: data, + } + _ = xcommand.SetDetail(&event, xcommand.Detail{Line: "/status", Presentation: "preformatted"}) + markdown := BuildTimelineMarkdown([]TimelineEntry{{Timestamp: mustTimelineTime(t, event.TS), Type: event.Type, Data: &event}}) + if !strings.Contains(markdown, "```\none\ntwo\n```") { + t.Fatalf("timeline markdown = %q", markdown) + } +} + func mustTimelineTime(t *testing.T, value string) time.Time { t.Helper() parsed, err := time.Parse(time.RFC3339Nano, value) diff --git a/core/runner/runtime_protocol.go b/core/runner/runtime_protocol.go index 97111dde..aeb9a4ec 100644 --- a/core/runner/runtime_protocol.go +++ b/core/runner/runtime_protocol.go @@ -9,6 +9,14 @@ import ( "github.com/chainreactors/aiscan/pkg/webproto" ) +func RuntimeCommandSpecs() []webproto.CommandSpec { + return []webproto.CommandSpec{ + {Name: "/status", Description: "Show Runtime session and provider status"}, + {Name: "/clear", Description: "Clear the current Agent context"}, + {Name: "/compact", Usage: "/compact [focus]", Description: "Compact the current Agent context"}, + } +} + // HandleProtocol handles the transport-neutral Agent Runtime control frames. // The caller owns framing and I/O; AgentRuntime owns all Session and Run state. func (rt *AgentRuntime) HandleProtocol(ctx context.Context, msg webproto.Message, send func(webproto.Message)) bool { @@ -92,9 +100,7 @@ func (rt *AgentRuntime) HandleProtocol(ctx context.Context, msg webproto.Message sendError("", msg.TaskID, err) return } - encoded, _ := json.Marshal(webproto.CommandResultPayload{ - SessionID: payload.SessionID, Parts: result.Parts, Metadata: result.Metadata, - }) + encoded, _ := json.Marshal(result) send(webproto.Message{Type: webproto.TypeCommandResult, TaskID: msg.TaskID, Payload: encoded}) }() return true diff --git a/core/runner/runtime_semantics_test.go b/core/runner/runtime_semantics_test.go index e2b616f7..e2b1c4e3 100644 --- a/core/runner/runtime_semantics_test.go +++ b/core/runner/runtime_semantics_test.go @@ -11,6 +11,7 @@ import ( "github.com/chainreactors/aiscan/pkg/agent" "github.com/chainreactors/aiscan/pkg/agent/inbox" "github.com/chainreactors/aiscan/pkg/aop" + xcommand "github.com/chainreactors/aiscan/pkg/aop/x/command" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" ) @@ -177,6 +178,10 @@ func TestCommandAddsAOPHistoryWithoutChangingTranscript(t *testing.T) { if commandEvent.Type != aop.TypeMessage || commandEvent.TurnID != "" { t.Fatalf("command AOP event = %+v", commandEvent) } + detail, ok, err := xcommand.GetDetail(commandEvent) + if err != nil || !ok || detail.Line != "!printf COMMAND_OK" || detail.Presentation != CommandPresentationPreformatted { + t.Fatalf("command extension = %+v ok=%v err=%v", detail, ok, err) + } after := session.MessagesSnapshot() if len(after) != len(before) { t.Fatalf("command changed transcript: before=%d after=%d", len(before), len(after)) diff --git a/core/runner/runtime_session.go b/core/runner/runtime_session.go index ed0d95f2..747f17ec 100644 --- a/core/runner/runtime_session.go +++ b/core/runner/runtime_session.go @@ -1,7 +1,6 @@ package runner import ( - "bytes" "context" "encoding/json" "errors" @@ -11,11 +10,11 @@ import ( "time" "github.com/chainreactors/aiscan/core/eventbus" - outputpkg "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/agent" "github.com/chainreactors/aiscan/pkg/agent/evaluator" inboxpkg "github.com/chainreactors/aiscan/pkg/agent/inbox" "github.com/chainreactors/aiscan/pkg/aop" + xcommand "github.com/chainreactors/aiscan/pkg/aop/x/command" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/pkg/tui" @@ -60,10 +59,16 @@ type RunResult struct { } type CommandResult struct { - Parts []aop.MessagePart - Metadata map[string]any + Command string `json:"command"` + Presentation string `json:"presentation,omitempty"` + Parts []aop.MessagePart `json:"parts,omitempty"` } +const ( + CommandPresentationPlain = "plain" + CommandPresentationPreformatted = "preformatted" +) + type Session struct { state *sessionState } @@ -179,36 +184,99 @@ func (s *commandSession) execute(ctx context.Context, input string) commandOutco if line == "/continue" || strings.HasPrefix(line, "/followup ") || strings.HasPrefix(line, "/skill:") { return commandOutcome{err: fmt.Errorf("%s requires a Run", line)} } - - var stdout, stderr bytes.Buffer ctx = commands.ContextWithInbox(ctx, s.state.inbox) ctx = agent.ContextWithLoopScheduler(ctx, s.state.scheduler) - option := s.state.runtime.option - if option != nil { - copy := *option - copy.NoColor = true - option = © - } - console := tui.NewAgentConsoleWithWriters(ctx, option, s.state.runtime.consoleAppInfo(), s.state.agent, &stdout, &stderr) - console.SetEvalCriteria(s.evalCriteria) - _, err := console.ExecuteLineAndWait(line) - s.evalCriteria = console.EvalCriteria() - out := strings.TrimRight(outputpkg.StripANSI(stdout.String()), " \t\r\n") - errOut := strings.TrimRight(outputpkg.StripANSI(stderr.String()), " \t\r\n") + + if strings.HasPrefix(line, "!") { + return s.executeBash(ctx, line, strings.TrimSpace(strings.TrimPrefix(line, "!"))) + } + args, err := commands.SplitCommandLine(line) if err != nil { - if errOut != "" { - err = fmt.Errorf("%s: %w", errOut, err) - } return commandOutcome{err: err} } - if out == "" { - out = errOut - } else if errOut != "" { - out = strings.TrimRight(out+"\n"+errOut, " \t\r\n") + if len(args) == 0 { + return commandOutcome{err: fmt.Errorf("command line is required")} + } + name := args[0] + values := args[1:] + switch name { + case "/help": + return commandText(line, CommandPresentationPreformatted, + "Runtime commands:\n /status\n /clear\n /compact [focus]\n /eval [criteria|off]\n /loop [interval prompt|list|stop name]\n !") + case "/status": + provider, model, _ := s.state.runtime.providerSnapshot() + providerName := "not configured" + if provider != nil { + providerName = provider.Name() + } + return commandText(line, CommandPresentationPreformatted, fmt.Sprintf( + "Session: %s\nAgent: %s\nProvider: %s\nModel: %s\nMessages: %d", + s.state.id, s.state.agentName, providerName, model, len(s.state.agent.MessagesSnapshot()))) + case "/clear": + s.state.agent.Reset() + return commandText(line, CommandPresentationPlain, "Context cleared.") + case "/compact": + if len(s.state.agent.MessagesSnapshot()) < 4 { + return commandText(line, CommandPresentationPlain, "Nothing to compact (too few messages).") + } + result, err := s.state.agent.Compact(ctx, agent.CompactConfig{CustomInstructions: strings.TrimSpace(strings.Join(values, " "))}) + if err != nil { + return commandOutcome{err: err} + } + return commandText(line, CommandPresentationPlain, fmt.Sprintf( + "Compacted: ~%d -> ~%d tokens (%d messages kept)", result.TokensBefore, result.TokensAfter, result.KeptMessages)) + case "/eval", "/goal": + criteria := strings.TrimSpace(strings.Join(values, " ")) + switch criteria { + case "": + if s.evalCriteria == "" { + return commandText(line, CommandPresentationPlain, "Goal evaluation: off") + } + return commandText(line, CommandPresentationPlain, "Goal evaluation: on\n criteria: "+s.evalCriteria) + case "off": + s.evalCriteria = "" + return commandText(line, CommandPresentationPlain, "Goal evaluation disabled.") + default: + s.evalCriteria = criteria + return commandText(line, CommandPresentationPlain, "Goal evaluation enabled: "+criteria) + } + case "/loop": + command := "loop" + if len(values) == 0 { + command += " list" + } else { + command += " " + strings.Join(values, " ") + } + return s.executeBash(ctx, line, command) + default: + return commandOutcome{err: fmt.Errorf("command %q is not a Runtime command", name)} + } +} + +func (s *commandSession) executeBash(ctx context.Context, line, command string) commandOutcome { + if command == "" { + return commandOutcome{err: fmt.Errorf("command is required after !")} + } + registry := s.state.runtime.app.Commands + if registry == nil { + return commandOutcome{err: fmt.Errorf("command registry is not available")} } - result := CommandResult{Metadata: map[string]any{"command": line}} - if out != "" { - result.Parts = []aop.MessagePart{{Type: aop.PartText, Text: out}} + bash, ok := registry.GetTool("bash") + if !ok { + return commandOutcome{err: fmt.Errorf("bash tool is not registered")} + } + payload, _ := json.Marshal(commands.BashArgs{Command: command}) + result, err := bash.Execute(ctx, string(payload)) + if err != nil { + return commandOutcome{err: err} + } + return commandText(line, CommandPresentationPreformatted, strings.TrimRight(result.Text(), " \t\r\n")) +} + +func commandText(line, presentation, text string) commandOutcome { + result := CommandResult{Command: line, Presentation: presentation} + if text != "" { + result.Parts = []aop.MessagePart{{Type: aop.PartText, Text: text}} } return commandOutcome{result: result} } @@ -735,7 +803,9 @@ func (s *sessionState) emitCommandResult(result CommandResult) { raw, _ := json.Marshal(aop.MessageData{ MessageID: s.runtime.nextRuntimeID("command"), Role: "assistant", Parts: result.Parts, }) - s.runtime.sessionEvents.emit(aop.Event{Type: aop.TypeMessage, SessionID: s.id, Agent: s.agentName, Data: raw}) + event := aop.Event{Type: aop.TypeMessage, SessionID: s.id, Agent: s.agentName, Data: raw} + _ = xcommand.SetDetail(&event, xcommand.Detail{Line: result.Command, Presentation: result.Presentation}) + s.runtime.sessionEvents.emit(event) } func (rt *AgentRuntime) pendingLimit() int { diff --git a/pkg/aop/x/command/command.go b/pkg/aop/x/command/command.go new file mode 100644 index 00000000..123965de --- /dev/null +++ b/pkg/aop/x/command/command.go @@ -0,0 +1,13 @@ +package command + +import "github.com/chainreactors/aiscan/pkg/aop" + +const NS = "command" + +type Detail struct { + Line string `json:"line"` + Presentation string `json:"presentation,omitempty"` +} + +func GetDetail(event aop.Event) (Detail, bool, error) { return aop.Ext[Detail](event, NS) } +func SetDetail(event *aop.Event, value Detail) error { return aop.SetExt(event, NS, value) } diff --git a/pkg/tui/commands.go b/pkg/tui/commands.go index ff781628..925b72b1 100644 --- a/pkg/tui/commands.go +++ b/pkg/tui/commands.go @@ -77,7 +77,7 @@ func SkillCommands(s *Session) []Command { } sk := skill cmds = append(cmds, Command{ - Name: "/" + sk.Name, + Name: "/skill:" + sk.Name, Description: sk.Description, Args: ArgsOptional, Run: func(ctx context.Context, s *Session, args []string) error { diff --git a/pkg/tui/console.go b/pkg/tui/console.go index 6ecede85..0dc558e5 100644 --- a/pkg/tui/console.go +++ b/pkg/tui/console.go @@ -443,6 +443,13 @@ func (r *AgentConsole) handleRuntimeInputLine(line string) (bool, error) { return r.appInfo.Run(ctx, prompt, false) }) } + if runtimeTUICommand(text) { + args, err := AgentConsoleArgsForLine(text) + if err != nil { + return false, err + } + return false, r.executeArgs(r.ctx, args) + } if strings.HasPrefix(text, "!") { return false, r.appInfo.Command(r.ctx, text) } @@ -455,6 +462,19 @@ func (r *AgentConsole) handleRuntimeInputLine(line string) (bool, error) { return false, r.appInfo.Command(r.ctx, text) } +func runtimeTUICommand(line string) bool { + name := strings.Fields(strings.TrimSpace(line)) + if len(name) == 0 { + return false + } + switch name[0] { + case "/help", "/resume", "/provider", "/model", "/spaces", "/messages", "/context", "/nodes": + return true + default: + return false + } +} + func (r *AgentConsole) promptString() string { return agentPromptString(r.ensureOutput()) } diff --git a/pkg/webagent/agent.go b/pkg/webagent/agent.go index b3923e73..142c920d 100644 --- a/pkg/webagent/agent.go +++ b/pkg/webagent/agent.go @@ -14,7 +14,6 @@ import ( "github.com/chainreactors/aiscan/pkg/agent" "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tui" "github.com/chainreactors/aiscan/pkg/webproto" "github.com/chainreactors/ioa/protocols" "github.com/chainreactors/utils/pty" @@ -239,33 +238,6 @@ func handleFileUpload(msg webproto.Message, send func(webproto.Message)) { }) } -// --------------------------------------------------------------------------- -// REPL helpers -// --------------------------------------------------------------------------- - -// fenceTerminalOutput wraps multi-line REPL/`!` command output in a Markdown -// code fence. runChatREPLLine runs the same TUI console the interactive REPL -// uses, whose panels (/status, /provider, /nodes ...) are drawn with box-drawing -// characters and column padding that only line up in a fixed-width, -// newline-preserving context. The web chat renders replies as Markdown prose, -// which collapses single newlines to spaces and uses a proportional font -- so an -// unfenced panel flattens into one mangled line. A fence makes the frontend -// render it verbatim in a monospace
. Single-line output (short status
-// confirmations like "Provider ready: ...") is left as prose.
-func fenceTerminalOutput(s string) string {
-	if !strings.Contains(s, "\n") {
-		return s
-	}
-	// Opening fence must be longer than any backtick run inside the payload
-	// (a `!cat` of a Markdown file could contain ```); grow it until it can't
-	// collide. Panel output never contains backticks, so this is just insurance.
-	fence := "```"
-	for strings.Contains(s, fence) {
-		fence += "`"
-	}
-	return fence + "\n" + s + "\n" + fence
-}
-
 // ---------------------------------------------------------------------------
 // Identity and command catalog (agent-specific, needs runner.AgentRuntime)
 // ---------------------------------------------------------------------------
@@ -275,9 +247,7 @@ func fenceTerminalOutput(s string) string {
 // non-internal) skill. The hub merges it with its hub-scope commands to build
 // the web "/" menu and /help, so the menu reflects what this agent can run.
 func agentCommandCatalog(app *runner.App) []webproto.CommandSpec {
-	// Build a zero-value console to extract command metadata without a live session.
-	r := &tui.AgentConsole{}
-	specs := tui.WebMenuSpecs(r.StaticCommands())
+	specs := runner.RuntimeCommandSpecs()
 	if app == nil || app.Skills == nil {
 		return specs
 	}
@@ -286,7 +256,7 @@ func agentCommandCatalog(app *runner.App) []webproto.CommandSpec {
 			continue
 		}
 		specs = append(specs, webproto.CommandSpec{
-			Name:        "/" + strings.TrimPrefix(strings.TrimSpace(sk.Name), "/"),
+			Name:        "/skill:" + strings.TrimPrefix(strings.TrimSpace(sk.Name), "/"),
 			Description: sk.Description,
 		})
 	}
diff --git a/pkg/webagent/agent_test.go b/pkg/webagent/agent_test.go
index 52a85fcd..defcdf1d 100644
--- a/pkg/webagent/agent_test.go
+++ b/pkg/webagent/agent_test.go
@@ -510,28 +510,3 @@ func frameHasSessionActivity(frame pty.Frame, sessionID string) bool {
 	}
 	return false
 }
-
-func TestFenceTerminalOutput(t *testing.T) {
-	// Single-line status stays prose — no fence.
-	if got := fenceTerminalOutput("Provider ready: anthropic / glm-5.2"); strings.Contains(got, "```") {
-		t.Errorf("single-line output should not be fenced, got %q", got)
-	}
-	// Multi-line panel (box art) gets fenced so the web renders it monospace.
-	panel := "╭────╮\n│ providers │\n╰────╯"
-	got := fenceTerminalOutput(panel)
-	if !strings.HasPrefix(got, "```\n") || !strings.HasSuffix(got, "\n```") {
-		t.Errorf("multi-line panel should be wrapped in a code fence, got %q", got)
-	}
-	if !strings.Contains(got, panel) {
-		t.Errorf("fenced output should preserve the panel verbatim, got %q", got)
-	}
-	// A payload containing a triple-backtick run grows the fence so it can't collide.
-	got = fenceTerminalOutput("line1\n```\nline2")
-	if !strings.HasPrefix(got, "````\n") {
-		t.Errorf("fence must be longer than an inner backtick run, got %q", got)
-	}
-	// Empty stays empty.
-	if got := fenceTerminalOutput(""); got != "" {
-		t.Errorf("empty input should stay empty, got %q", got)
-	}
-}
diff --git a/pkg/webproto/message.go b/pkg/webproto/message.go
index 2de98f76..e1225fb2 100644
--- a/pkg/webproto/message.go
+++ b/pkg/webproto/message.go
@@ -58,12 +58,6 @@ type CommandPayload struct {
 	Line      string `json:"line"`
 }
 
-type CommandResultPayload struct {
-	SessionID string            `json:"session_id"`
-	Parts     []aop.MessagePart `json:"parts,omitempty"`
-	Metadata  map[string]any    `json:"metadata,omitempty"`
-}
-
 type ErrorPayload struct {
 	Message string `json:"message"`
 }
diff --git a/web/frontend/src/components/ChatPanel.tsx b/web/frontend/src/components/ChatPanel.tsx
index 633a1070..bba50c86 100644
--- a/web/frontend/src/components/ChatPanel.tsx
+++ b/web/frontend/src/components/ChatPanel.tsx
@@ -116,6 +116,28 @@ function eventText(event: AOPEvent): string {
     .join('\n')
 }
 
+function markdownCodeFence(text: string): string {
+  let fence = '```'
+  while (text.includes(fence)) fence += '`'
+  return `${fence}\n${text}\n${fence}`
+}
+
+function presentAOPEvent(event: AOPEvent): AOPEvent {
+  if (event.type !== 'message') return event
+  const command = event.ext?.command as { presentation?: string } | undefined
+  if (command?.presentation !== 'preformatted') return event
+  const data = event.data as { parts?: Array<{ type?: string; text?: string }> }
+  return {
+    ...event,
+    data: {
+      ...data,
+      parts: (data.parts ?? []).map((part) => (
+        part.type === 'text' && part.text ? { ...part, text: markdownCodeFence(part.text) } : part
+      )),
+    },
+  }
+}
+
 function extensionBlock(event: AOPEvent): Record {
   for (const value of Object.values(event.ext ?? {})) {
     if (value && typeof value === 'object') return value as Record
@@ -145,7 +167,7 @@ function reduceConversationAOP(
 
   const childIDs = new Set(childStarts.keys())
   const topLevel = reduceAOPToTimeline(
-    events.filter((event) => !childIDs.has(event.session_id)),
+    events.filter((event) => !childIDs.has(event.session_id)).map(presentAOPEvent),
     { streaming, lifecycle: 'errors' },
   ) as ViewerTimelineItem[]
 
@@ -170,7 +192,7 @@ function reduceConversationAOP(
           ? 'canceled'
           : 'completed'
     const timestamp = Date.parse(start.ts)
-    const items = reduceAOPToTimeline(childEvents, {
+    const items = reduceAOPToTimeline(childEvents.map(presentAOPEvent), {
       streaming: streaming && !end,
       lifecycle: 'errors',
     }).filter((item) => item.kind !== 'divider' || item.variant === 'warning') as ViewerTimelineItem[]

From 102a3635b71b6d990e4fb6571f5e3cf79fc6ed9a Mon Sep 17 00:00:00 2001
From: M09Ic 
Date: Sat, 25 Jul 2026 13:37:28 +0800
Subject: [PATCH 8/9] docs: remove obsolete agent compatibility references

---
 docs/mechanisms.md        | 28 ++++++++++------------------
 pkg/tui/remote_console.go | 10 +++++-----
 2 files changed, 15 insertions(+), 23 deletions(-)

diff --git a/docs/mechanisms.md b/docs/mechanisms.md
index 6b375acf..a86cfbc6 100644
--- a/docs/mechanisms.md
+++ b/docs/mechanisms.md
@@ -217,27 +217,19 @@ agent 端的 skill 命令和 `!bash` 从浏览器也能用。
 
 ---
 
-## 12. completeAssistantRun 始终广播
+## 12. Agent 生命周期统一由 AOP 驱动
 
-**问题**: 旧 `persistAssistantMessage` 在 content 为空时跳过广播和持久化。tool-only turn 或 eval 命中轮次上限时 UI 卡在 streaming indicator。
+**问题**: 旧 Web 路径通过 `completeAssistantRun` 合成终止消息,并另外持久化中间轮次。它与 Runtime 已产生的 AOP message/turn 生命周期重复,tool-only turn 还需要额外的空消息规则才能释放 UI 状态。
 
-**机制**: 新 `completeAssistantRun` **始终广播** terminal message event,但只在有文本时持久化。空回复不留空行,UI 正常释放 composer。
+**机制**: Runtime 产生的 typed AOP event 是 Agent 消息、工具调用和 turn 状态的唯一语义来源。Web 层直接转发和持久化这些事件,不再合成第二套 assistant 完成事件,也不再为中间轮次维护独立的聊天事件协议。
 
-**文件**: `pkg/web/service.go`
+scan、agent joined、session cleared 等产品事件保留独立的 `DomainEvent`,不携带 Agent 的 role/content/message ID 字段。
 
----
-
-## 13. message_end 中间轮持久化
-
-**问题**: 多轮对话中只有最终聚合回复被持久化(`completeAssistantRun`),中间每轮的 assistant 文本只在 SSE 流中出现,页面刷新后消失。
-
-**机制**: `persistRuntimeChatEvent` 新增 `ChatEventMessageEnd` case。每轮非空的 finalized text 存为 assistant message,带 turn 元数据。`buildTimelineFromMessages` 按 turn 归到正确的气泡。streaming partials (message_start/message_delta) 不持久化。
-
-**文件**: `pkg/web/service.go`
+**文件**: `core/runner/`, `pkg/aop/`, `pkg/web/service.go`
 
 ---
 
-## 14. TUI 渲染改进
+## 13. TUI 渲染改进
 
 ### CJK 感知宽度
 
@@ -255,15 +247,15 @@ agent 端的 skill 命令和 `!bash` 从浏览器也能用。
 
 `redactIOAURL` 剥离 `http://@host/ioa` 中的 userinfo,防止 token 泄露到终端/截图。
 
-### fenceTerminalOutput
+### 命令展示边界
 
-REPL 多行输出(box-drawing panel)在 web chat 中包裹 code fence,让前端以 monospace `
` 渲染。单行输出保持 prose。fence 长度自适应避免与内容中的 backtick 冲突。
+跨界面 Runtime 命令通过 typed AOP command detail 标记 `presentation: preformatted`。Web timeline 在最终展示边界生成自适应 Markdown code fence;Runtime、Session 和 transport 不再处理 Markdown 或终端格式。
 
-**文件**: `pkg/tui/banner.go`, `pkg/tui/commands.go`, `pkg/tui/ioa.go`, `pkg/webagent/agent.go`
+**文件**: `pkg/tui/banner.go`, `pkg/tui/commands.go`, `pkg/tui/ioa.go`, `pkg/aop/x/command/command.go`, `core/output/timeline.go`
 
 ---
 
-## 15. 环境变量优先级修正
+## 14. 环境变量优先级修正
 
 旧逻辑中 provider-scoped env(如 `ANTHROPIC_MODEL`)和 aiscan 自有 env(`AISCAN_MODEL`)在 `else if` 链中平级。hub 启动的 agent 继承 hub 环境后,Settings UI 配置的 model 被环境变量覆盖。
 
diff --git a/pkg/tui/remote_console.go b/pkg/tui/remote_console.go
index 17ba1c57..dd8193f1 100644
--- a/pkg/tui/remote_console.go
+++ b/pkg/tui/remote_console.go
@@ -13,13 +13,13 @@ import (
 	rlterm "github.com/chainreactors/tui/readline/terminal"
 )
 
-// AgentEventSubscriber connects a console-local renderer to the runtime AOP
+// AOPEventSubscriber connects a console-local renderer to the runtime AOP
 // bus and returns an unsubscribe function owned by that console attachment.
-type AgentEventSubscriber func(func(aop.Event)) func()
+type AOPEventSubscriber func(func(aop.Event)) func()
 
 // RunRemoteAgentConsoleWithControl adapts a byte-stream terminal while keeping
 // event rendering scoped to the attached agent session.
-func RunRemoteAgentConsoleWithControl(ctx context.Context, option *cfg.Option, appInfo AppInfo, session *agent.Agent, input io.Reader, output io.Writer, control *rlterm.StreamControl, subscribers ...AgentEventSubscriber) error {
+func RunRemoteAgentConsoleWithControl(ctx context.Context, option *cfg.Option, appInfo AppInfo, session *agent.Agent, input io.Reader, output io.Writer, control *rlterm.StreamControl, subscribers ...AOPEventSubscriber) error {
 	if control == nil {
 		control = rlterm.NewControl(true, 80, 24)
 	}
@@ -30,7 +30,7 @@ func RunRemoteAgentConsoleWithControl(ctx context.Context, option *cfg.Option, a
 // RunAgentConsoleWithTerminal creates the renderer and readline console for an
 // explicit terminal. Local callers pass the process terminal directly so
 // control sequences are never buffered and replayed through a PTY.
-func RunAgentConsoleWithTerminal(ctx context.Context, option *cfg.Option, appInfo AppInfo, session *agent.Agent, terminal *rlterm.Terminal, subscribers ...AgentEventSubscriber) error {
+func RunAgentConsoleWithTerminal(ctx context.Context, option *cfg.Option, appInfo AppInfo, session *agent.Agent, terminal *rlterm.Terminal, subscribers ...AOPEventSubscriber) error {
 	if terminal == nil {
 		return fmt.Errorf("terminal is nil")
 	}
@@ -43,7 +43,7 @@ func RunAgentConsoleWithTerminal(ctx context.Context, option *cfg.Option, appInf
 
 // subscribeAgentOutput filters the shared runtime bus by session ID so a
 // remote or local REPL cannot render sibling/subagent events accidentally.
-func subscribeAgentOutput(output *AgentOutput, session *agent.Agent, subscribers ...AgentEventSubscriber) func() {
+func subscribeAgentOutput(output *AgentOutput, session *agent.Agent, subscribers ...AOPEventSubscriber) func() {
 	if output == nil || session == nil || len(subscribers) == 0 || subscribers[0] == nil {
 		return func() {}
 	}

From aa433b248b8ef92f8eda22e993641a218f0edc01 Mon Sep 17 00:00:00 2001
From: M09Ic 
Date: Sun, 26 Jul 2026 12:02:45 +0800
Subject: [PATCH 9/9] fix(ci): update tool test paths after package move

---
 .github/workflows/ci.yml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 45c1416e..dec57644 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -142,7 +142,7 @@ jobs:
       - name: Run proxy tool tests
         run: |
           go test -tags "re2_cgo re2_static" -race -count=1 -timeout 5m -v \
-            ./pkg/tools/proxy/
+            ./tools/proxy/
 
       - name: Run tmux command tests
         run: |
@@ -182,7 +182,7 @@ jobs:
         run: |
           go test -tags "full re2_cgo re2_static" -count=1 -timeout 5m -v \
             -run 'Test(ScannerFunctionalRegression|FullScannerFunctionalRegression)$' \
-            ./pkg/tools
+            ./tools
 
   # ── Generated templates tests (depends on tidy) ───────────────