e071084ebe
govulncheck / govulncheck (push) Waiting to run
Harness (E2E) / Harnesses (mock LLM) (push) Waiting to run
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Waiting to run
Lint / golangci-lint (push) Waiting to run
Run Tests / Unit Tests (push) Waiting to run
Run Tests / Etcd Integration Tests (push) Waiting to run
323 lines
8.4 KiB
Go
323 lines
8.4 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"go-micro.dev/v6/ai"
|
|
)
|
|
|
|
// StreamEventType identifies an event emitted by a tool-aware agent stream.
|
|
type StreamEventType string
|
|
|
|
const (
|
|
// StreamEventToolStart is emitted immediately before a tool call runs.
|
|
StreamEventToolStart StreamEventType = "tool_start"
|
|
// StreamEventToolEnd is emitted after a tool call returns or is refused.
|
|
StreamEventToolEnd StreamEventType = "tool_end"
|
|
// StreamEventToken carries a chunk of the final answer.
|
|
StreamEventToken StreamEventType = "token"
|
|
// StreamEventDone carries the completed agent response.
|
|
StreamEventDone StreamEventType = "done"
|
|
)
|
|
|
|
// StreamEvent is one event from StreamAsk.
|
|
type StreamEvent struct {
|
|
Type StreamEventType
|
|
Token string
|
|
ToolCall ai.ToolCall
|
|
Result ai.ToolResult
|
|
Response *Response
|
|
}
|
|
|
|
// AgentStream is a stream of tool execution events followed by final-answer chunks.
|
|
type AgentStream interface {
|
|
Recv() (*StreamEvent, error)
|
|
Close() error
|
|
}
|
|
|
|
// StreamAsk runs an agent Ask turn with tool start/end events and streams the final answer.
|
|
// It is additive for callers that hold the public Agent interface; concrete agents also
|
|
// expose the same method directly.
|
|
func StreamAsk(ctx context.Context, ag Agent, message string) (AgentStream, error) {
|
|
streamer, ok := ag.(interface {
|
|
StreamAsk(context.Context, string) (AgentStream, error)
|
|
})
|
|
if !ok {
|
|
return nil, errors.New("agent: StreamAsk unsupported by implementation")
|
|
}
|
|
return streamer.StreamAsk(ctx, message)
|
|
}
|
|
|
|
// ResumeStreamAsk resumes a checkpointed agent run and emits the same event
|
|
// shape as StreamAsk. Completed runs are streamed from the persisted response;
|
|
// unfinished runs continue from their checkpoint and emit tool events for any
|
|
// work that still needs to run. Tool calls already recorded as done in the
|
|
// checkpoint are reused by the agent checkpoint wrapper and are not re-executed.
|
|
func ResumeStreamAsk(ctx context.Context, ag Agent, runID string) (AgentStream, error) {
|
|
a, ok := ag.(*agentImpl)
|
|
if !ok {
|
|
return nil, errors.New("agent: ResumeStreamAsk unsupported by implementation")
|
|
}
|
|
return a.resumeStreamAsk(ctx, runID)
|
|
}
|
|
|
|
// StreamAsk runs tools like Ask, emits ToolStart/ToolEnd events as they execute,
|
|
// then emits chunks of the final answer followed by a Done event.
|
|
func (a *agentImpl) StreamAsk(ctx context.Context, message string) (AgentStream, error) {
|
|
streamCtx, cancel := context.WithCancel(ctx)
|
|
events := make(chan *StreamEvent, 16)
|
|
done := make(chan struct{})
|
|
s := &agentStream{events: events, done: done, cancel: cancel}
|
|
|
|
go func() {
|
|
defer close(events)
|
|
defer close(done)
|
|
resp, err := a.askWithStreamEvents(streamCtx, message, events)
|
|
if err != nil {
|
|
s.setErr(err)
|
|
return
|
|
}
|
|
for _, tok := range splitStreamTokens(resp.Reply) {
|
|
if !sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToken, Token: tok}) {
|
|
return
|
|
}
|
|
}
|
|
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventDone, Response: resp})
|
|
}()
|
|
return s, nil
|
|
}
|
|
|
|
func (a *agentImpl) resumeStreamAsk(ctx context.Context, runID string) (AgentStream, error) {
|
|
streamCtx, cancel := context.WithCancel(ctx)
|
|
events := make(chan *StreamEvent, 16)
|
|
done := make(chan struct{})
|
|
s := &agentStream{events: events, done: done, cancel: cancel}
|
|
|
|
go func() {
|
|
defer close(events)
|
|
defer close(done)
|
|
resp, err := a.resumeWithStreamEvents(streamCtx, runID, events)
|
|
if err != nil {
|
|
s.setErr(err)
|
|
return
|
|
}
|
|
for _, tok := range splitStreamTokens(resp.Reply) {
|
|
if !sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToken, Token: tok}) {
|
|
return
|
|
}
|
|
}
|
|
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventDone, Response: resp})
|
|
}()
|
|
return s, nil
|
|
}
|
|
|
|
func (a *agentImpl) askWithStreamEvents(ctx context.Context, message string, events chan<- *StreamEvent) (*Response, error) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
|
|
if a.tools == nil {
|
|
a.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
|
|
}
|
|
base := a.toolHandler()
|
|
handler := func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
|
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToolStart, ToolCall: call})
|
|
result := base(ctx, call)
|
|
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToolEnd, ToolCall: call, Result: result})
|
|
return result
|
|
}
|
|
a.setupWithToolHandler(handler)
|
|
defer a.setupWithToolHandler(nil)
|
|
return a.askLocked(ctx, uuid.New().String(), message, a.parentRunID, nil, true)
|
|
}
|
|
|
|
func (a *agentImpl) resumeWithStreamEvents(ctx context.Context, runID string, events chan<- *StreamEvent) (*Response, error) {
|
|
if a.opts.Checkpoint == nil {
|
|
return nil, errors.New("agent: ResumeStreamAsk requires a checkpoint")
|
|
}
|
|
run, ok, err := a.opts.Checkpoint.Load(ctx, runID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
return nil, errors.New("agent: checkpointed run not found")
|
|
}
|
|
if run.Status == "done" {
|
|
var resp Response
|
|
if err := json.Unmarshal(run.State.Data, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return &resp, nil
|
|
}
|
|
if terminalAgentRunStatus(run.Status) {
|
|
return nil, errors.New("agent: checkpointed run is terminal with status " + run.Status)
|
|
}
|
|
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.tools == nil {
|
|
a.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
|
|
}
|
|
base := a.toolHandler()
|
|
handler := func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
|
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToolStart, ToolCall: call})
|
|
result := base(ctx, call)
|
|
_ = sendStreamEvent(ctx, events, &StreamEvent{Type: StreamEventToolEnd, ToolCall: call, Result: result})
|
|
return result
|
|
}
|
|
a.setupWithToolHandler(handler)
|
|
defer a.setupWithToolHandler(nil)
|
|
if run.Status == "paused" {
|
|
if run.State.Stage == agentInputStep {
|
|
return nil, errors.New("agent: checkpointed run is input-required; resume with ResumeInput")
|
|
}
|
|
run.Status = "running"
|
|
run.State.Stage = agentAskStep
|
|
}
|
|
return a.askLocked(ctx, run.ID, string(run.State.Data), run.ParentID, &run, false)
|
|
}
|
|
|
|
type agentStreamAdapter struct {
|
|
stream AgentStream
|
|
}
|
|
|
|
type memoryRecordingStream struct {
|
|
stream ai.Stream
|
|
memory Memory
|
|
|
|
mu sync.Mutex
|
|
chunks []string
|
|
closed bool
|
|
}
|
|
|
|
func (s *memoryRecordingStream) Recv() (*ai.Response, error) {
|
|
resp, err := s.stream.Recv()
|
|
if resp != nil && resp.Reply != "" {
|
|
s.mu.Lock()
|
|
s.chunks = append(s.chunks, resp.Reply)
|
|
s.mu.Unlock()
|
|
}
|
|
if errors.Is(err, io.EOF) {
|
|
s.recordAssistant()
|
|
}
|
|
return resp, err
|
|
}
|
|
|
|
func (s *memoryRecordingStream) Close() error {
|
|
s.recordAssistant()
|
|
return s.stream.Close()
|
|
}
|
|
|
|
func (s *memoryRecordingStream) recordAssistant() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closed {
|
|
return
|
|
}
|
|
s.closed = true
|
|
if reply := strings.Join(s.chunks, ""); reply != "" {
|
|
s.memory.Add("assistant", reply)
|
|
}
|
|
}
|
|
|
|
func (s *agentStreamAdapter) Recv() (*ai.Response, error) {
|
|
for {
|
|
event, err := s.stream.Recv()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if event == nil {
|
|
continue
|
|
}
|
|
switch event.Type {
|
|
case StreamEventToken:
|
|
if event.Token == "" {
|
|
continue
|
|
}
|
|
return &ai.Response{Reply: event.Token}, nil
|
|
case StreamEventDone:
|
|
return nil, io.EOF
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *agentStreamAdapter) Close() error {
|
|
return s.stream.Close()
|
|
}
|
|
|
|
func (a *agentImpl) streamAskAI(ctx context.Context, message string) (ai.Stream, error) {
|
|
stream, err := a.StreamAsk(ctx, message)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &agentStreamAdapter{stream: stream}, nil
|
|
}
|
|
|
|
type agentStream struct {
|
|
events <-chan *StreamEvent
|
|
done <-chan struct{}
|
|
cancel context.CancelFunc
|
|
mu sync.Mutex
|
|
err error
|
|
}
|
|
|
|
func (s *agentStream) Recv() (*StreamEvent, error) {
|
|
ev, ok := <-s.events
|
|
if ok {
|
|
return ev, nil
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.err != nil {
|
|
return nil, s.err
|
|
}
|
|
return nil, io.EOF
|
|
}
|
|
|
|
func (s *agentStream) Close() error {
|
|
if s.cancel != nil {
|
|
s.cancel()
|
|
}
|
|
<-s.done
|
|
return nil
|
|
}
|
|
|
|
func (s *agentStream) setErr(err error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.err = err
|
|
}
|
|
|
|
func sendStreamEvent(ctx context.Context, events chan<- *StreamEvent, ev *StreamEvent) bool {
|
|
select {
|
|
case events <- ev:
|
|
return true
|
|
case <-ctx.Done():
|
|
return false
|
|
}
|
|
}
|
|
|
|
func splitStreamTokens(reply string) []string {
|
|
if reply == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Fields(reply)
|
|
if len(parts) == 0 {
|
|
return []string{reply}
|
|
}
|
|
out := make([]string, 0, len(parts))
|
|
for i, part := range parts {
|
|
if i > 0 {
|
|
part = " " + part
|
|
}
|
|
out = append(out, part)
|
|
}
|
|
return out
|
|
}
|