Merge pull request #3117 from not-the-author/codex/fix-vision-routing-and-onboard-embed
fix(agent): route media turns to image models
This commit is contained in:
commit
15196b9cc9
14 changed files with 1222 additions and 146 deletions
|
|
@ -1,14 +1,12 @@
|
||||||
package onboard
|
package onboard
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
picoclaw "github.com/sipeed/picoclaw"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:generate go run ../../../../scripts/copydir.go ../../../../workspace ./workspace
|
var embeddedFiles = picoclaw.OnboardWorkspace
|
||||||
//go:embed workspace
|
|
||||||
var embeddedFiles embed.FS
|
|
||||||
|
|
||||||
func NewOnboardCommand() *cobra.Command {
|
func NewOnboardCommand() *cobra.Command {
|
||||||
var encrypt bool
|
var encrypt bool
|
||||||
|
|
|
||||||
12
onboard_workspace_embed.go
Normal file
12
onboard_workspace_embed.go
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
package picoclaw
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
// OnboardWorkspace embeds the default onboarding workspace template.
|
||||||
|
//
|
||||||
|
// Keeping this embed at the module root lets us source files directly from the
|
||||||
|
// tracked `workspace/` tree, instead of relying on a generated copy inside
|
||||||
|
// `cmd/...` that may be absent in clean checkouts and CI lint runs.
|
||||||
|
//
|
||||||
|
//go:embed workspace
|
||||||
|
var OnboardWorkspace embed.FS
|
||||||
|
|
@ -296,7 +296,7 @@ func registerSharedTools(
|
||||||
// This keeps subagent vision support working even when the optimized
|
// This keeps subagent vision support working even when the optimized
|
||||||
// sub-turn spawner path is unavailable.
|
// sub-turn spawner path is unavailable.
|
||||||
subagentManager.SetMediaResolver(func(msgs []providers.Message) []providers.Message {
|
subagentManager.SetMediaResolver(func(msgs []providers.Message) []providers.Message {
|
||||||
return resolveMediaRefs(msgs, al.mediaStore, cfg.Agents.Defaults.GetMaxMediaSize())
|
return resolveMediaRefs(msgs, al.mediaStore, cfg.Agents.Defaults.GetMaxMediaSize(), 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Set the spawner that links into AgentLoop's turnState
|
// Set the spawner that links into AgentLoop's turnState
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,21 @@ var (
|
||||||
filePlaceholderRegex = regexp.MustCompile(`\[file(:\s+[^\]]*)?\]`)
|
filePlaceholderRegex = regexp.MustCompile(`\[file(:\s+[^\]]*)?\]`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func normalizeCurrentTurnStart(messages []providers.Message, currentTurnStart int) int {
|
||||||
|
if currentTurnStart < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if currentTurnStart > len(messages) {
|
||||||
|
return len(messages)
|
||||||
|
}
|
||||||
|
return currentTurnStart
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentTurnMessages(messages []providers.Message, currentTurnStart int) []providers.Message {
|
||||||
|
currentTurnStart = normalizeCurrentTurnStart(messages, currentTurnStart)
|
||||||
|
return messages[currentTurnStart:]
|
||||||
|
}
|
||||||
|
|
||||||
// resolveMediaRefs resolves media:// refs in messages.
|
// resolveMediaRefs resolves media:// refs in messages.
|
||||||
// For user messages: images get path tags only ([image:/path]) so the LLM
|
// For user messages: images get path tags only ([image:/path]) so the LLM
|
||||||
// can decide whether to view them via load_image or operate on the file.
|
// can decide whether to view them via load_image or operate on the file.
|
||||||
|
|
@ -38,12 +53,20 @@ var (
|
||||||
// user message only after the contiguous tool-message block ends, so we don't
|
// user message only after the contiguous tool-message block ends, so we don't
|
||||||
// break the tool-results-must-immediately-follow-assistant constraint that
|
// break the tool-results-must-immediately-follow-assistant constraint that
|
||||||
// LLM APIs enforce.
|
// LLM APIs enforce.
|
||||||
|
// Only tool messages from the current turn may emit the synthetic user
|
||||||
|
// follow-up; historical tool results stay as plain path-tagged history.
|
||||||
// Non-image files always get path tags regardless of role.
|
// Non-image files always get path tags regardless of role.
|
||||||
// Returns a new slice; original messages are not mutated.
|
// Returns a new slice; original messages are not mutated.
|
||||||
func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message {
|
func resolveMediaRefs(
|
||||||
|
messages []providers.Message,
|
||||||
|
store media.MediaStore,
|
||||||
|
maxSize int,
|
||||||
|
currentTurnStart int,
|
||||||
|
) []providers.Message {
|
||||||
if store == nil {
|
if store == nil {
|
||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
currentTurnStart = normalizeCurrentTurnStart(messages, currentTurnStart)
|
||||||
|
|
||||||
result := make([]providers.Message, 0, len(messages))
|
result := make([]providers.Message, 0, len(messages))
|
||||||
var pendingToolImages []string
|
var pendingToolImages []string
|
||||||
|
|
@ -52,22 +75,14 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
|
||||||
// When leaving a tool-message block, flush any accumulated images
|
// When leaving a tool-message block, flush any accumulated images
|
||||||
// as a synthetic user message.
|
// as a synthetic user message.
|
||||||
if m.Role != "tool" && len(pendingToolImages) > 0 {
|
if m.Role != "tool" && len(pendingToolImages) > 0 {
|
||||||
result = append(result, providers.Message{
|
result = append(result, toolImageFollowUpPromptMessage(pendingToolImages))
|
||||||
Role: "user",
|
|
||||||
Content: "[Loaded image from tool result above]",
|
|
||||||
Media: pendingToolImages,
|
|
||||||
})
|
|
||||||
pendingToolImages = nil
|
pendingToolImages = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(m.Media) == 0 {
|
if len(m.Media) == 0 {
|
||||||
result = append(result, m)
|
result = append(result, m)
|
||||||
if idx == len(messages)-1 && len(pendingToolImages) > 0 {
|
if idx == len(messages)-1 && len(pendingToolImages) > 0 {
|
||||||
result = append(result, providers.Message{
|
result = append(result, toolImageFollowUpPromptMessage(pendingToolImages))
|
||||||
Role: "user",
|
|
||||||
Content: "[Loaded image from tool result above]",
|
|
||||||
Media: pendingToolImages,
|
|
||||||
})
|
|
||||||
pendingToolImages = nil
|
pendingToolImages = nil
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|
@ -104,7 +119,7 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
|
||||||
mime := detectMIME(localPath, meta)
|
mime := detectMIME(localPath, meta)
|
||||||
pathTags = append(pathTags, buildPathTag(mime, localPath))
|
pathTags = append(pathTags, buildPathTag(mime, localPath))
|
||||||
|
|
||||||
if m.Role == "tool" && strings.HasPrefix(mime, "image/") {
|
if m.Role == "tool" && idx >= currentTurnStart && strings.HasPrefix(mime, "image/") {
|
||||||
dataURL := encodeImageToDataURL(localPath, mime, info, maxSize)
|
dataURL := encodeImageToDataURL(localPath, mime, info, maxSize)
|
||||||
if dataURL != "" {
|
if dataURL != "" {
|
||||||
pendingToolImages = append(pendingToolImages, dataURL)
|
pendingToolImages = append(pendingToolImages, dataURL)
|
||||||
|
|
@ -120,11 +135,7 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
|
||||||
|
|
||||||
// If this is the last message and we have pending images, flush them.
|
// If this is the last message and we have pending images, flush them.
|
||||||
if idx == len(messages)-1 && len(pendingToolImages) > 0 {
|
if idx == len(messages)-1 && len(pendingToolImages) > 0 {
|
||||||
result = append(result, providers.Message{
|
result = append(result, toolImageFollowUpPromptMessage(pendingToolImages))
|
||||||
Role: "user",
|
|
||||||
Content: "[Loaded image from tool result above]",
|
|
||||||
Media: pendingToolImages,
|
|
||||||
})
|
|
||||||
pendingToolImages = nil
|
pendingToolImages = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -43,6 +43,7 @@ type AgentInstance struct {
|
||||||
SkillsFilter []string
|
SkillsFilter []string
|
||||||
MCPServerAllowlist map[string]struct{}
|
MCPServerAllowlist map[string]struct{}
|
||||||
Candidates []providers.FallbackCandidate
|
Candidates []providers.FallbackCandidate
|
||||||
|
ImageCandidates []providers.FallbackCandidate
|
||||||
|
|
||||||
// Router is non-nil when model routing is configured and the light model
|
// Router is non-nil when model routing is configured and the light model
|
||||||
// was successfully resolved. It scores each incoming message and decides
|
// was successfully resolved. It scores each incoming message and decides
|
||||||
|
|
@ -198,9 +199,19 @@ func NewAgentInstance(
|
||||||
|
|
||||||
// Resolve fallback candidates
|
// Resolve fallback candidates
|
||||||
candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks)
|
candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks)
|
||||||
|
imageCandidates := resolveModelCandidates(
|
||||||
|
cfg,
|
||||||
|
defaults.Provider,
|
||||||
|
defaults.ImageModel,
|
||||||
|
defaults.ImageModelFallbacks,
|
||||||
|
)
|
||||||
|
|
||||||
candidateProviders := make(map[string]providers.LLMProvider)
|
candidateProviders := make(map[string]providers.LLMProvider)
|
||||||
populateCandidateProvidersFromNames(cfg, workspace, fallbacks, candidateProviders)
|
populateCandidateProvidersFromNames(cfg, workspace, fallbacks, candidateProviders)
|
||||||
|
if strings.TrimSpace(defaults.ImageModel) != "" {
|
||||||
|
imageNames := append([]string{defaults.ImageModel}, defaults.ImageModelFallbacks...)
|
||||||
|
populateCandidateProvidersFromNames(cfg, workspace, imageNames, candidateProviders)
|
||||||
|
}
|
||||||
|
|
||||||
// Model routing setup: pre-resolve light model candidates at creation time
|
// Model routing setup: pre-resolve light model candidates at creation time
|
||||||
// to avoid repeated model_list lookups on every incoming message.
|
// to avoid repeated model_list lookups on every incoming message.
|
||||||
|
|
@ -265,6 +276,7 @@ func NewAgentInstance(
|
||||||
SkillsFilter: skillsFilter,
|
SkillsFilter: skillsFilter,
|
||||||
MCPServerAllowlist: agentMCPServerAllowlist,
|
MCPServerAllowlist: agentMCPServerAllowlist,
|
||||||
Candidates: candidates,
|
Candidates: candidates,
|
||||||
|
ImageCandidates: imageCandidates,
|
||||||
Router: router,
|
Router: router,
|
||||||
LightCandidates: lightCandidates,
|
LightCandidates: lightCandidates,
|
||||||
LightProvider: lightProvider,
|
LightProvider: lightProvider,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var resolvedImagePathTagRegex = regexp.MustCompile(`\[image:[^\s\]][^\]]*\]`)
|
||||||
|
|
||||||
func messagesContainMedia(messages []providers.Message) bool {
|
func messagesContainMedia(messages []providers.Message) bool {
|
||||||
for _, msg := range messages {
|
for _, msg := range messages {
|
||||||
for _, ref := range msg.Media {
|
for _, ref := range msg.Media {
|
||||||
|
|
@ -65,3 +70,125 @@ func isVisionUnsupportedError(err error) bool {
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func visionUnsupportedModelError(modelName string, imageModelConfigured bool) error {
|
||||||
|
modelName = strings.TrimSpace(modelName)
|
||||||
|
if imageModelConfigured {
|
||||||
|
if modelName != "" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"selected vision model %q does not support image input; update agents.defaults.image_model to a multimodal model",
|
||||||
|
modelName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return fmt.Errorf(
|
||||||
|
"selected vision model does not support image input; update agents.defaults.image_model to a multimodal model",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if modelName != "" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"active model %q does not support image input; configure agents.defaults.image_model with a multimodal model",
|
||||||
|
modelName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return fmt.Errorf(
|
||||||
|
"the active model does not support image input; configure agents.defaults.image_model with a multimodal model",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameCandidateSet(a, b []providers.FallbackCandidate) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i].StableKey() != b[i].StableKey() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func messagesContainCurrentTurnMediaTurn(messages []providers.Message) bool {
|
||||||
|
for _, msg := range messages {
|
||||||
|
if len(msg.Media) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if resolvedImagePathTagRegex.MatchString(msg.Content) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pipeline) routeMediaTurn(ts *turnState, exec *turnExecution) error {
|
||||||
|
if p == nil || ts == nil || ts.agent == nil || exec == nil ||
|
||||||
|
!messagesContainCurrentTurnMediaTurn(currentTurnMessages(exec.callMessages, exec.currentTurnStart)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetCandidates []providers.FallbackCandidate
|
||||||
|
var targetModelName string
|
||||||
|
var routeReason string
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case len(ts.agent.ImageCandidates) > 0:
|
||||||
|
targetCandidates = append([]providers.FallbackCandidate(nil), ts.agent.ImageCandidates...)
|
||||||
|
targetModelName = strings.TrimSpace(p.Cfg.Agents.Defaults.ImageModel)
|
||||||
|
routeReason = "configured_image_model"
|
||||||
|
case exec.usedLight && len(ts.agent.Candidates) > 0:
|
||||||
|
targetCandidates = append([]providers.FallbackCandidate(nil), ts.agent.Candidates...)
|
||||||
|
targetModelName = strings.TrimSpace(ts.agent.Model)
|
||||||
|
routeReason = "bypass_light_model_for_media"
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(targetCandidates) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
targetModel := resolvedCandidateModel(targetCandidates, targetModelName)
|
||||||
|
targetProvider := exec.activeProvider
|
||||||
|
firstCandidate := targetCandidates[0]
|
||||||
|
if provider, err := providerForFallbackCandidate(
|
||||||
|
ts.agent,
|
||||||
|
ts.agent.Provider,
|
||||||
|
targetCandidates,
|
||||||
|
firstCandidate.Provider,
|
||||||
|
firstCandidate.Model,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
} else if provider != nil {
|
||||||
|
targetProvider = provider
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedModelName := resolvedCandidateModelName(targetCandidates, targetModelName)
|
||||||
|
if sameCandidateSet(exec.activeCandidates, targetCandidates) &&
|
||||||
|
exec.activeModel == targetModel &&
|
||||||
|
exec.llmModelName == resolvedModelName {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
exec.activeCandidates = targetCandidates
|
||||||
|
exec.activeModel = targetModel
|
||||||
|
exec.activeProvider = targetProvider
|
||||||
|
exec.activeModelConfig = resolveActiveModelConfig(
|
||||||
|
p.Cfg,
|
||||||
|
ts.agent.Workspace,
|
||||||
|
targetCandidates,
|
||||||
|
targetModel,
|
||||||
|
p.Cfg.Agents.Defaults.Provider,
|
||||||
|
)
|
||||||
|
exec.llmModelName = resolvedModelName
|
||||||
|
exec.usedLight = false
|
||||||
|
|
||||||
|
logger.InfoCF("agent", "Media turn routing selected model", map[string]any{
|
||||||
|
"agent_id": ts.agent.ID,
|
||||||
|
"reason": routeReason,
|
||||||
|
"model": exec.activeModel,
|
||||||
|
"model_name": exec.llmModelName,
|
||||||
|
"candidates": len(exec.activeCandidates),
|
||||||
|
"messages_count": len(exec.callMessages),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -295,21 +295,16 @@ toolLoop:
|
||||||
contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
|
contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
|
||||||
}
|
}
|
||||||
|
|
||||||
toolResultMsg := providers.Message{
|
var toolResultMedia []string
|
||||||
Role: "tool",
|
|
||||||
Content: contentForLLM,
|
|
||||||
ToolCallID: tc.ID,
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(hookResult.Media) > 0 && !hookResult.ResponseHandled {
|
if len(hookResult.Media) > 0 && !hookResult.ResponseHandled {
|
||||||
hookResult.ArtifactTags = buildArtifactTags(al.mediaStore, hookResult.Media)
|
hookResult.ArtifactTags = buildArtifactTags(al.mediaStore, hookResult.Media)
|
||||||
contentForLLM = hookResult.ContentForLLM()
|
contentForLLM = hookResult.ContentForLLM()
|
||||||
if al.cfg.Tools.IsFilterSensitiveDataEnabled() {
|
if al.cfg.Tools.IsFilterSensitiveDataEnabled() {
|
||||||
contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
|
contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
|
||||||
}
|
}
|
||||||
toolResultMsg.Content = contentForLLM
|
toolResultMedia = append(toolResultMedia, hookResult.Media...)
|
||||||
toolResultMsg.Media = append(toolResultMsg.Media, hookResult.Media...)
|
|
||||||
}
|
}
|
||||||
|
toolResultMsg := toolResultPromptMessage(contentForLLM, tc.ID, toolResultMedia)
|
||||||
|
|
||||||
al.emitEvent(
|
al.emitEvent(
|
||||||
runtimeevents.KindAgentToolExecEnd,
|
runtimeevents.KindAgentToolExecEnd,
|
||||||
|
|
@ -695,14 +690,11 @@ toolLoop:
|
||||||
contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
|
contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
|
||||||
}
|
}
|
||||||
|
|
||||||
toolResultMsg := providers.Message{
|
var toolResultMedia []string
|
||||||
Role: "tool",
|
|
||||||
Content: contentForLLM,
|
|
||||||
ToolCallID: toolCallID,
|
|
||||||
}
|
|
||||||
if len(toolResult.Media) > 0 && !toolResult.ResponseHandled {
|
if len(toolResult.Media) > 0 && !toolResult.ResponseHandled {
|
||||||
toolResultMsg.Media = append(toolResultMsg.Media, toolResult.Media...)
|
toolResultMedia = append(toolResultMedia, toolResult.Media...)
|
||||||
}
|
}
|
||||||
|
toolResultMsg := toolResultPromptMessage(contentForLLM, toolCallID, toolResultMedia)
|
||||||
al.emitEvent(
|
al.emitEvent(
|
||||||
runtimeevents.KindAgentToolExecEnd,
|
runtimeevents.KindAgentToolExecEnd,
|
||||||
ts.eventMeta("runTurn", "turn.tool.end"),
|
ts.eventMeta("runTurn", "turn.tool.end"),
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ func (p *Pipeline) CallLLM(
|
||||||
|
|
||||||
// PreLLM: resolve media refs (except on iteration 1 where user media is already resolved)
|
// PreLLM: resolve media refs (except on iteration 1 where user media is already resolved)
|
||||||
if iteration > 1 {
|
if iteration > 1 {
|
||||||
exec.messages = resolveMediaRefs(exec.messages, p.MediaStore, maxMediaSize)
|
exec.messages = resolveMediaRefs(exec.messages, p.MediaStore, maxMediaSize, exec.currentTurnStart)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PreLLM: graceful terminal handling
|
// PreLLM: graceful terminal handling
|
||||||
|
|
@ -64,6 +64,9 @@ func (p *Pipeline) CallLLM(
|
||||||
exec.providerToolDefs = nil
|
exec.providerToolDefs = nil
|
||||||
ts.markGracefulTerminalUsed()
|
ts.markGracefulTerminalUsed()
|
||||||
}
|
}
|
||||||
|
if err := p.routeMediaTurn(ts, exec); err != nil {
|
||||||
|
return ControlBreak, err
|
||||||
|
}
|
||||||
|
|
||||||
exec.llmOpts = map[string]any{
|
exec.llmOpts = map[string]any{
|
||||||
"max_tokens": ts.agent.MaxTokens,
|
"max_tokens": ts.agent.MaxTokens,
|
||||||
|
|
@ -170,11 +173,10 @@ func (p *Pipeline) CallLLM(
|
||||||
return response, streamErr
|
return response, streamErr
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(exec.activeCandidates) > 1 && p.Fallback != nil {
|
runCandidate := func(
|
||||||
fbResult, fbErr := p.Fallback.ExecuteCandidate(
|
ctx context.Context,
|
||||||
providerCtx,
|
candidate providers.FallbackCandidate,
|
||||||
exec.activeCandidates,
|
) (*providers.LLMResponse, error) {
|
||||||
func(ctx context.Context, candidate providers.FallbackCandidate) (*providers.LLMResponse, error) {
|
|
||||||
candidateProvider, err := providerForFallbackCandidate(
|
candidateProvider, err := providerForFallbackCandidate(
|
||||||
ts.agent,
|
ts.agent,
|
||||||
exec.activeProvider,
|
exec.activeProvider,
|
||||||
|
|
@ -198,8 +200,35 @@ func (p *Pipeline) CallLLM(
|
||||||
applyThinkingOption(callOpts, candidateProvider, candidateThinking, true, ts.agent.ID)
|
applyThinkingOption(callOpts, candidateProvider, candidateThinking, true, ts.agent.ID)
|
||||||
exec.suppressReasoning = shouldSuppressReasoningFor(candidateThinking)
|
exec.suppressReasoning = shouldSuppressReasoningFor(candidateThinking)
|
||||||
return candidateProvider.Chat(ctx, messagesForCall, toolDefsForCall, candidate.Model, callOpts)
|
return candidateProvider.Chat(ctx, messagesForCall, toolDefsForCall, candidate.Model, callOpts)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(exec.activeCandidates) > 1 && p.Fallback != nil {
|
||||||
|
var (
|
||||||
|
fbResult *providers.FallbackResult
|
||||||
|
fbErr error
|
||||||
|
)
|
||||||
|
if hasMediaRefs(messagesForCall) {
|
||||||
|
fbResult, fbErr = p.Fallback.ExecuteImage(
|
||||||
|
providerCtx,
|
||||||
|
exec.activeCandidates,
|
||||||
|
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
|
||||||
|
candidate := providers.FallbackCandidate{Provider: provider, Model: model}
|
||||||
|
for _, configured := range exec.activeCandidates {
|
||||||
|
if configured.Provider == provider && configured.Model == model {
|
||||||
|
candidate = configured
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return runCandidate(ctx, candidate)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
fbResult, fbErr = p.Fallback.ExecuteCandidate(
|
||||||
|
providerCtx,
|
||||||
|
exec.activeCandidates,
|
||||||
|
runCandidate,
|
||||||
|
)
|
||||||
|
}
|
||||||
if fbErr != nil {
|
if fbErr != nil {
|
||||||
return nil, fbErr
|
return nil, fbErr
|
||||||
}
|
}
|
||||||
|
|
@ -250,33 +279,11 @@ func (p *Pipeline) CallLLM(
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retry without media if vision is unsupported
|
if hasMediaRefs(exec.callMessages) && isVisionUnsupportedError(err) {
|
||||||
if hasMediaRefs(exec.callMessages) && isVisionUnsupportedError(err) && retry < maxRetries {
|
return ControlBreak, visionUnsupportedModelError(
|
||||||
al.emitEvent(
|
exec.llmModelName,
|
||||||
runtimeevents.KindAgentLLMRetry,
|
len(ts.agent.ImageCandidates) > 0,
|
||||||
ts.eventMeta("runTurn", "turn.llm.retry"),
|
|
||||||
LLMRetryPayload{
|
|
||||||
Attempt: retry + 1,
|
|
||||||
MaxRetries: maxRetries,
|
|
||||||
Reason: "vision_unsupported",
|
|
||||||
Error: err.Error(),
|
|
||||||
Backoff: 0,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
logger.WarnCF("agent", "Vision unsupported, retrying without media", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
"retry": retry,
|
|
||||||
})
|
|
||||||
exec.callMessages = stripMessageMedia(exec.callMessages)
|
|
||||||
if !ts.opts.NoHistory {
|
|
||||||
exec.history = stripMessageMedia(exec.history)
|
|
||||||
ts.agent.Sessions.SetHistory(ts.sessionKey, exec.history)
|
|
||||||
for i := range ts.persistedMessages {
|
|
||||||
ts.persistedMessages[i].Media = nil
|
|
||||||
}
|
|
||||||
ts.refreshRestorePointFromSession(ts.agent)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
errMsg := strings.ToLower(err.Error())
|
errMsg := strings.ToLower(err.Error())
|
||||||
|
|
@ -381,7 +388,13 @@ func (p *Pipeline) CallLLM(
|
||||||
fullHistory := append(append([]providers.Message(nil), trimmedHistory...), protectedTurnTail...)
|
fullHistory := append(append([]providers.Message(nil), trimmedHistory...), protectedTurnTail...)
|
||||||
rebuildPromptReq := promptBuildRequestForTurn(ts, fullHistory, exec.summary, "", nil, p.Cfg)
|
rebuildPromptReq := promptBuildRequestForTurn(ts, fullHistory, exec.summary, "", nil, p.Cfg)
|
||||||
rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
|
rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
|
||||||
return ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
|
rebuilt := ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
|
||||||
|
return resolveMediaRefs(
|
||||||
|
rebuilt,
|
||||||
|
p.MediaStore,
|
||||||
|
maxMediaSize,
|
||||||
|
len(rebuilt)-len(protectedTurnTail),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
originalHistoryCount := len(exec.history)
|
originalHistoryCount := len(exec.history)
|
||||||
var fit bool
|
var fit bool
|
||||||
|
|
@ -401,6 +414,7 @@ func (p *Pipeline) CallLLM(
|
||||||
)
|
)
|
||||||
exec.history = append(trimmedStableHistory, protectedTurnTail...)
|
exec.history = append(trimmedStableHistory, protectedTurnTail...)
|
||||||
exec.messages = buildMessages(trimmedStableHistory)
|
exec.messages = buildMessages(trimmedStableHistory)
|
||||||
|
exec.currentTurnStart = len(exec.messages) - len(protectedTurnTail)
|
||||||
if exec.gracefulTerminal {
|
if exec.gracefulTerminal {
|
||||||
msgs := append([]providers.Message(nil), exec.messages...)
|
msgs := append([]providers.Message(nil), exec.messages...)
|
||||||
exec.callMessages = append(msgs, ts.interruptHintMessage())
|
exec.callMessages = append(msgs, ts.interruptHintMessage())
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,12 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
|
||||||
initialPromptReq := promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media, cfg)
|
initialPromptReq := promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media, cfg)
|
||||||
initialPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
|
initialPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
|
||||||
messages := ts.agent.ContextBuilder.BuildMessagesFromPrompt(initialPromptReq)
|
messages := ts.agent.ContextBuilder.BuildMessagesFromPrompt(initialPromptReq)
|
||||||
|
currentTurnStart := len(messages)
|
||||||
|
if strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0 {
|
||||||
|
currentTurnStart = len(messages) - 1
|
||||||
|
}
|
||||||
|
|
||||||
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
|
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize, currentTurnStart)
|
||||||
|
|
||||||
if !ts.opts.NoHistory {
|
if !ts.opts.NoHistory {
|
||||||
toolDefs := filterToolsByTurnProfile(ts.agent.Tools.ToProviderDefs(), ts.profile)
|
toolDefs := filterToolsByTurnProfile(ts.agent.Tools.ToProviderDefs(), ts.profile)
|
||||||
|
|
@ -81,7 +85,11 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
|
||||||
)
|
)
|
||||||
rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
|
rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
|
||||||
rebuilt := ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
|
rebuilt := ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
|
||||||
return resolveMediaRefs(rebuilt, p.MediaStore, maxMediaSize)
|
rebuiltCurrentTurnStart := len(rebuilt)
|
||||||
|
if strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0 {
|
||||||
|
rebuiltCurrentTurnStart = len(rebuilt) - 1
|
||||||
|
}
|
||||||
|
return resolveMediaRefs(rebuilt, p.MediaStore, maxMediaSize, rebuiltCurrentTurnStart)
|
||||||
},
|
},
|
||||||
ts.agent.ContextWindow,
|
ts.agent.ContextWindow,
|
||||||
toolDefs,
|
toolDefs,
|
||||||
|
|
@ -137,6 +145,7 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
|
||||||
summary,
|
summary,
|
||||||
messages,
|
messages,
|
||||||
)
|
)
|
||||||
|
exec.currentTurnStart = currentTurnStart
|
||||||
exec.activeCandidates = activeCandidates
|
exec.activeCandidates = activeCandidates
|
||||||
exec.activeModel = activeModel
|
exec.activeModel = activeModel
|
||||||
exec.activeModelConfig = resolveActiveModelConfig(
|
exec.activeModelConfig = resolveActiveModelConfig(
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ const (
|
||||||
PromptSlotMessage PromptSlot = "message"
|
PromptSlotMessage PromptSlot = "message"
|
||||||
PromptSlotSteering PromptSlot = "steering"
|
PromptSlotSteering PromptSlot = "steering"
|
||||||
PromptSlotSubTurn PromptSlot = "subturn"
|
PromptSlotSubTurn PromptSlot = "subturn"
|
||||||
|
PromptSlotToolResult PromptSlot = "tool_result"
|
||||||
PromptSlotInterrupt PromptSlot = "interrupt"
|
PromptSlotInterrupt PromptSlot = "interrupt"
|
||||||
PromptSlotOutput PromptSlot = "output"
|
PromptSlotOutput PromptSlot = "output"
|
||||||
)
|
)
|
||||||
|
|
@ -60,6 +61,7 @@ const (
|
||||||
PromptSourceUserMessage PromptSourceID = "turn:user_message"
|
PromptSourceUserMessage PromptSourceID = "turn:user_message"
|
||||||
PromptSourceSteering PromptSourceID = "turn:steering"
|
PromptSourceSteering PromptSourceID = "turn:steering"
|
||||||
PromptSourceSubTurnResult PromptSourceID = "turn:subturn_result"
|
PromptSourceSubTurnResult PromptSourceID = "turn:subturn_result"
|
||||||
|
PromptSourceToolResult PromptSourceID = "turn:tool_result"
|
||||||
PromptSourceInterrupt PromptSourceID = "turn:interrupt"
|
PromptSourceInterrupt PromptSourceID = "turn:interrupt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,29 @@ func userPromptMessage(content string, media []string) providers.Message {
|
||||||
return promptMessageWithMetadata(msg, PromptLayerTurn, PromptSlotMessage, PromptSourceUserMessage)
|
return promptMessageWithMetadata(msg, PromptLayerTurn, PromptSlotMessage, PromptSourceUserMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toolResultPromptMessage(content, toolCallID string, media []string) providers.Message {
|
||||||
|
msg := providers.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: content,
|
||||||
|
ToolCallID: toolCallID,
|
||||||
|
}
|
||||||
|
if len(media) > 0 {
|
||||||
|
msg.Media = append([]string(nil), media...)
|
||||||
|
}
|
||||||
|
return promptMessageWithMetadata(msg, PromptLayerTurn, PromptSlotToolResult, PromptSourceToolResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolImageFollowUpPromptMessage(media []string) providers.Message {
|
||||||
|
msg := providers.Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: "[Loaded image from tool result above]",
|
||||||
|
}
|
||||||
|
if len(media) > 0 {
|
||||||
|
msg.Media = append([]string(nil), media...)
|
||||||
|
}
|
||||||
|
return promptMessageWithMetadata(msg, PromptLayerTurn, PromptSlotToolResult, PromptSourceToolResult)
|
||||||
|
}
|
||||||
|
|
||||||
func steeringPromptMessage(msg providers.Message) providers.Message {
|
func steeringPromptMessage(msg providers.Message) providers.Message {
|
||||||
return promptMessageWithDefaultMetadata(msg, PromptLayerTurn, PromptSlotSteering, PromptSourceSteering)
|
return promptMessageWithDefaultMetadata(msg, PromptLayerTurn, PromptSlotSteering, PromptSourceSteering)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
|
||||||
|
|
||||||
// Inject pending steering messages
|
// Inject pending steering messages
|
||||||
if len(pendingMessages) > 0 {
|
if len(pendingMessages) > 0 {
|
||||||
resolvedPending := resolveMediaRefs(pendingMessages, al.mediaStore, maxMediaSize)
|
resolvedPending := resolveMediaRefs(pendingMessages, al.mediaStore, maxMediaSize, 0)
|
||||||
totalContentLen := 0
|
totalContentLen := 0
|
||||||
for i, pm := range pendingMessages {
|
for i, pm := range pendingMessages {
|
||||||
messages = append(messages, resolvedPending[i])
|
messages = append(messages, resolvedPending[i])
|
||||||
|
|
@ -431,7 +431,11 @@ func (al *AgentLoop) askSideQuestion(
|
||||||
messages := agent.ContextBuilder.BuildMessagesFromPrompt(promptReq)
|
messages := agent.ContextBuilder.BuildMessagesFromPrompt(promptReq)
|
||||||
|
|
||||||
maxMediaSize := al.GetConfig().Agents.Defaults.GetMaxMediaSize()
|
maxMediaSize := al.GetConfig().Agents.Defaults.GetMaxMediaSize()
|
||||||
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
|
currentTurnStart := len(messages)
|
||||||
|
if strings.TrimSpace(question) != "" || len(media) > 0 {
|
||||||
|
currentTurnStart = len(messages) - 1
|
||||||
|
}
|
||||||
|
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize, currentTurnStart)
|
||||||
|
|
||||||
activeCandidates, activeModel, usedLight := al.selectCandidates(agent, question, messages)
|
activeCandidates, activeModel, usedLight := al.selectCandidates(agent, question, messages)
|
||||||
selectedModelName := sideQuestionModelName(agent, usedLight)
|
selectedModelName := sideQuestionModelName(agent, usedLight)
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ type turnExecution struct {
|
||||||
pendingMessages []providers.Message // steering/SubTurn messages awaiting injection
|
pendingMessages []providers.Message // steering/SubTurn messages awaiting injection
|
||||||
history []providers.Message // from ContextManager.Assemble
|
history []providers.Message // from ContextManager.Assemble
|
||||||
summary string
|
summary string
|
||||||
|
currentTurnStart int
|
||||||
|
|
||||||
// Turn output
|
// Turn output
|
||||||
finalContent string
|
finalContent string
|
||||||
|
|
@ -168,6 +169,7 @@ func newTurnExecution(
|
||||||
summary: summary,
|
summary: summary,
|
||||||
messages: messages,
|
messages: messages,
|
||||||
pendingMessages: append([]providers.Message(nil), opts.InitialSteeringMessages...),
|
pendingMessages: append([]providers.Message(nil), opts.InitialSteeringMessages...),
|
||||||
|
currentTurnStart: len(messages),
|
||||||
iteration: 0,
|
iteration: 0,
|
||||||
phase: LLMPhaseSetup,
|
phase: LLMPhaseSetup,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue