agent: simplify configuration/instantiation layers
- Kill agentConfig struct. run() takes (*Runtime, TurnCtx, state) directly. TurnCtx holds only 7 per-turn closures; stable config reads come from Runtime. - Lazy tool refresh: toolsNeedRefresh() checks ToolRegistryRevision() on the tool server. Skips ListTools IPC when registry hasn't changed. Remote Conn falls back to always-refetch (same as before). - AgentConfig.GenParams() method replaces the 16-line manual field copy in BuildRuntime. - Remove CfgBackend/CfgModel from Runtime. Callers read cfg.Backend/cfg.Model directly since they already have the AgentConfig in scope.
This commit is contained in:
parent
8f7d21fc38
commit
32ff54f708
|
|
@ -22,8 +22,7 @@ import (
|
|||
type Agent struct {
|
||||
history *History
|
||||
runtime *Runtime
|
||||
cfg agentConfig // per-turn config built from runtime
|
||||
name string // display name (defaults to id)
|
||||
name string // display name (defaults to id)
|
||||
profile string // config profile name (e.g. "default")
|
||||
agentsDir string
|
||||
systemPrompt string // system prompt for /agent reloads
|
||||
|
|
@ -493,6 +492,25 @@ func (ag *Agent) wireToolsChanged() {
|
|||
}
|
||||
}
|
||||
|
||||
// toolsNeedRefresh reports whether the tool registry has changed since the
|
||||
// last time Tools was populated. If the tool server doesn't support revision
|
||||
// tracking (e.g. remote Conn), this always returns true (safe fallback).
|
||||
func (ag *Agent) toolsNeedRefresh() bool {
|
||||
type revisionProvider interface {
|
||||
ToolRegistryRevision() uint64
|
||||
}
|
||||
srv, ok := ag.runtime.ToolServer.(revisionProvider)
|
||||
if !ok {
|
||||
return true // no revision support — always refetch
|
||||
}
|
||||
rev := srv.ToolRegistryRevision()
|
||||
if rev != ag.runtime.toolRegistryRevision {
|
||||
ag.runtime.toolRegistryRevision = rev
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SaveFull persists the full session state (history + metadata) to the given path.
|
||||
func (ag *Agent) SaveFull(path, sessionID, sessionName, cwd, remote string) error {
|
||||
// Switching profiles intentionally clears the conversation history, but
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package agent
|
|||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"ollie/backend"
|
||||
)
|
||||
|
||||
// Prompt holds the agent prompt. It unmarshals from either a JSON string
|
||||
|
|
@ -86,3 +88,25 @@ func Load(r io.Reader) (*AgentConfig, error) {
|
|||
func (c *AgentConfig) ToolsEnabled() bool {
|
||||
return c.Tools == nil || *c.Tools
|
||||
}
|
||||
|
||||
// GenParams returns the generation parameters derived from this config.
|
||||
func (c *AgentConfig) GenParams() backend.GenerationParams {
|
||||
return backend.GenerationParams{
|
||||
MaxTokens: c.MaxTokens,
|
||||
MaxCompletionTokens: c.MaxCompletionTokens,
|
||||
Temperature: c.Temperature,
|
||||
TopP: c.TopP,
|
||||
TopK: c.TopK,
|
||||
MinP: c.MinP,
|
||||
TopA: c.TopA,
|
||||
FrequencyPenalty: c.FrequencyPenalty,
|
||||
PresencePenalty: c.PresencePenalty,
|
||||
RepetitionPenalty: c.RepetitionPenalty,
|
||||
ThinkingBudget: c.Reasoning,
|
||||
ReasoningEffort: c.ReasoningEffort,
|
||||
IncludeReasoning: c.IncludeReasoning,
|
||||
ResponseFormat: c.ResponseFormat,
|
||||
Stop: c.Stop,
|
||||
Verbosity: c.Verbosity,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,24 +43,7 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string
|
|||
} else {
|
||||
agentPrompt = resolved
|
||||
}
|
||||
genParams = backend.GenerationParams{
|
||||
MaxTokens: cfg.MaxTokens,
|
||||
MaxCompletionTokens: cfg.MaxCompletionTokens,
|
||||
Temperature: cfg.Temperature,
|
||||
TopP: cfg.TopP,
|
||||
TopK: cfg.TopK,
|
||||
MinP: cfg.MinP,
|
||||
TopA: cfg.TopA,
|
||||
FrequencyPenalty: cfg.FrequencyPenalty,
|
||||
PresencePenalty: cfg.PresencePenalty,
|
||||
RepetitionPenalty: cfg.RepetitionPenalty,
|
||||
ThinkingBudget: cfg.Reasoning,
|
||||
ReasoningEffort: cfg.ReasoningEffort,
|
||||
IncludeReasoning: cfg.IncludeReasoning,
|
||||
ResponseFormat: cfg.ResponseFormat,
|
||||
Stop: cfg.Stop,
|
||||
Verbosity: cfg.Verbosity,
|
||||
}
|
||||
genParams = cfg.GenParams()
|
||||
maxSteps = cfg.MaxSteps
|
||||
if len(cfg.AllowTools) > 0 {
|
||||
if rs, ok := srv.(interface{ SetAllowTools([]string) }); ok {
|
||||
|
|
@ -131,10 +114,8 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string
|
|||
}
|
||||
}
|
||||
|
||||
var backendName, modelName, compactionModel string
|
||||
var compactionModel string
|
||||
if cfg != nil {
|
||||
backendName = cfg.Backend
|
||||
modelName = cfg.Model
|
||||
compactionModel = cfg.CompactionModel
|
||||
}
|
||||
|
||||
|
|
@ -151,8 +132,6 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string
|
|||
Preamble: preamble,
|
||||
GenParams: genParams,
|
||||
MaxSteps: maxSteps,
|
||||
CfgBackend: backendName,
|
||||
CfgModel: modelName,
|
||||
CompactionModel: compactionModel,
|
||||
Messages: messages,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -183,20 +183,20 @@ func (ag *Agent) cmdAgent(_ context.Context, args []string) {
|
|||
env := []string{"OLLIE_SESSION_ID=" + ag.sessionID, "OLLIE_UNAME=" + ag.id}
|
||||
env = append(env, ag.promptEnvExtra...)
|
||||
rt := BuildRuntime(cfg, disp, ag.cwd, env, ag.systemPrompt, ag.envBlock)
|
||||
if rt.CfgBackend != "" {
|
||||
newBe, err := ag.newBackend(rt.CfgBackend)
|
||||
if cfg.Backend != "" {
|
||||
newBe, err := ag.newBackend(cfg.Backend)
|
||||
if err != nil {
|
||||
ag.emit(infoEvent(fmt.Sprintf("error: backend %q: %v", rt.CfgBackend, err)))
|
||||
ag.emit(infoEvent(fmt.Sprintf("error: backend %q: %v", cfg.Backend, err)))
|
||||
return
|
||||
}
|
||||
if rt.CfgModel != "" {
|
||||
newBe.SetModel(rt.CfgModel)
|
||||
if cfg.Model != "" {
|
||||
newBe.SetModel(cfg.Model)
|
||||
}
|
||||
rt.Backend = newBe
|
||||
} else {
|
||||
rt.Backend = ag.runtime.Backend
|
||||
if rt.CfgModel != "" {
|
||||
rt.Backend.SetModel(rt.CfgModel)
|
||||
if cfg.Model != "" {
|
||||
rt.Backend.SetModel(cfg.Model)
|
||||
}
|
||||
}
|
||||
ag.runtime = rt
|
||||
|
|
|
|||
219
agent/loop.go
219
agent/loop.go
|
|
@ -98,43 +98,26 @@ func fileStat(path string) (int64, int64) {
|
|||
return info.ModTime().UnixNano(), info.Size()
|
||||
}
|
||||
|
||||
func toolOutputFormat(cfg agentConfig, name string) string {
|
||||
if cfg.OutputFormat == nil {
|
||||
func toolOutputFormat(rt *Runtime, name string) string {
|
||||
if rt.OutputFormat == nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.OutputFormat(name)
|
||||
return rt.OutputFormat(name)
|
||||
}
|
||||
|
||||
type agentConfig struct {
|
||||
Backend backend.Backend
|
||||
Tools []backend.Tool
|
||||
Exec toolExecutor
|
||||
ClassifyTool func(name string) bool // nil=treat all as serial; true=parallel-read-safe
|
||||
OutputFormat func(name string) string // empty means plaintext fence
|
||||
ClassifyTier func(name string, args json.RawMessage) ResultTier // nil=TierHot for all; tools self-classify retention
|
||||
ResetsCounter func(name string) bool // nil=false for all; true=reset step counter
|
||||
Output EventHandler
|
||||
preamble string // compiled system+agent prompt sent as the system role
|
||||
GenerationParams backend.GenerationParams
|
||||
PopInject func() string // returns and clears pending inject, or ""
|
||||
AutoCompact func(ctx context.Context) // called after each tool round; may compact in-place
|
||||
Save func() // called after each state.update(); persists mid-turn progress
|
||||
// MaxSteps is the maximum number of tool-call rounds per turn.
|
||||
// When reached, a soft nudge is injected and the loop exits cleanly.
|
||||
// 0 means unlimited.
|
||||
MaxSteps int
|
||||
// ReadPlanStep returns the next unchecked step from the plan file, or "".
|
||||
// Used as the primary source for TaskState.PlanStep; falls back to
|
||||
// heuristic extraction from assistant text if nil.
|
||||
ReadPlanStep func() string
|
||||
// IncrToolCallCount increments and returns the session-wide tool call counter.
|
||||
// TurnCtx holds per-turn closures and state that vary between turns within
|
||||
// the same agent. Everything else comes from *Runtime.
|
||||
type TurnCtx struct {
|
||||
Output EventHandler
|
||||
PopInject func() string // returns and clears pending inject, or ""
|
||||
AutoCompact func(ctx context.Context) // called after each tool round; may compact in-place
|
||||
Save func() // called after each state.update(); persists mid-turn progress
|
||||
ReadPlanStep func() string // next unchecked step from plan file, or ""
|
||||
IncrToolCallCount func() int64
|
||||
// ResultCache persists read-safe tool results across turns.
|
||||
// Cleared on write operations (file_write, file_edit).
|
||||
ResultCache *sync.Map
|
||||
ResultCache *sync.Map
|
||||
}
|
||||
|
||||
func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||
func run(ctx context.Context, rt *Runtime, tc TurnCtx, state state) error {
|
||||
var step int
|
||||
var consecutiveErrors int // rounds where every tool call returned an error
|
||||
var roundsWithoutPlan int // consecutive tool rounds without a PLAN: block
|
||||
|
|
@ -145,22 +128,22 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
// resultCache stores outputs of read-safe tool calls keyed by name+args.
|
||||
// Only tools classified as parallel-safe (immutable reads) are cached.
|
||||
// sync.Map is required because execOne may run in concurrent goroutines.
|
||||
// Uses the cross-turn cache from agentConfig if available.
|
||||
resultCache := cfg.ResultCache
|
||||
// Uses the cross-turn cache from TurnCtx if available.
|
||||
resultCache := tc.ResultCache
|
||||
if resultCache == nil {
|
||||
resultCache = &sync.Map{}
|
||||
}
|
||||
|
||||
for {
|
||||
emit(cfg, Event{Role: "state", Content: "thinking"})
|
||||
emit(tc, Event{Role: "state", Content: "thinking"})
|
||||
// Proactive context gate: strip cold material before calling the backend.
|
||||
if budget := contextBudget(ctx, cfg.Backend); budget > 0 && state.estimateTokens() > budget {
|
||||
state.stripCold(ctx, cfg.Backend)
|
||||
if budget := contextBudget(ctx, rt.Backend); budget > 0 && state.estimateTokens() > budget {
|
||||
state.stripCold(ctx, rt.Backend)
|
||||
}
|
||||
|
||||
history := state.history()
|
||||
if cfg.preamble != "" {
|
||||
history = append([]backend.Message{{Role: "system", Content: cfg.preamble}}, history...)
|
||||
if rt.Preamble != "" {
|
||||
history = append([]backend.Message{{Role: "system", Content: rt.Preamble}}, history...)
|
||||
}
|
||||
if ts := state.taskState(); ts != nil {
|
||||
if msg := ts.render(); msg != "" && len(history) > 0 {
|
||||
|
|
@ -183,7 +166,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
reasoning.Reset()
|
||||
toolCalls = nil
|
||||
|
||||
ch, err := cfg.Backend.ChatStream(ctx, history, cfg.Tools, cfg.GenerationParams)
|
||||
ch, err := rt.Backend.ChatStream(ctx, history, rt.Tools, rt.GenParams)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
|
|
@ -194,9 +177,9 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
}
|
||||
var rlErr *backend.RateLimitError
|
||||
if errors.As(err, &rlErr) {
|
||||
emit(cfg, Event{Role: "limitretry"})
|
||||
emit(tc, Event{Role: "limitretry"})
|
||||
}
|
||||
if err := retryCountdown(ctx, cfg, wait); err != nil {
|
||||
if err := retryCountdown(ctx, tc, wait); err != nil {
|
||||
return fmt.Errorf("step %d: %w", step, err)
|
||||
}
|
||||
continue
|
||||
|
|
@ -207,30 +190,30 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
for ev := range ch {
|
||||
if ev.Reasoning != "" {
|
||||
if !hadReasoning {
|
||||
emit(cfg, Event{Role: "reasoning", Content: "<think>\n"})
|
||||
emit(tc, Event{Role: "reasoning", Content: "<think>\n"})
|
||||
hadReasoning = true
|
||||
}
|
||||
reasoning.WriteString(ev.Reasoning)
|
||||
emit(cfg, Event{Role: "reasoning", Content: ev.Reasoning})
|
||||
emit(tc, Event{Role: "reasoning", Content: ev.Reasoning})
|
||||
}
|
||||
if ev.Content != "" {
|
||||
if hadReasoning {
|
||||
emit(cfg, Event{Role: "reasoning", Content: "\n</think>\n"})
|
||||
emit(tc, Event{Role: "reasoning", Content: "\n</think>\n"})
|
||||
hadReasoning = false
|
||||
}
|
||||
content.WriteString(ev.Content)
|
||||
emit(cfg, Event{Role: "assistant", Content: ev.Content, ResponseID: responseID})
|
||||
emit(tc, Event{Role: "assistant", Content: ev.Content, ResponseID: responseID})
|
||||
}
|
||||
toolCalls = append(toolCalls, ev.ToolCalls...)
|
||||
if ev.Done {
|
||||
if hadReasoning {
|
||||
emit(cfg, Event{Role: "reasoning", Content: "\n</think>\n"})
|
||||
emit(tc, Event{Role: "reasoning", Content: "\n</think>\n"})
|
||||
hadReasoning = false
|
||||
}
|
||||
stopReason = ev.StopReason
|
||||
done = true
|
||||
if ev.Usage.InputTokens > 0 || ev.Usage.OutputTokens > 0 {
|
||||
emit(cfg, Event{
|
||||
emit(tc, Event{
|
||||
Role: "usage",
|
||||
Content: fmt.Sprintf("%d %d 0 %g %d %d", ev.Usage.InputTokens, ev.Usage.OutputTokens, ev.Usage.CostUSD, ev.Usage.CachedInputTokens, ev.Usage.CacheCreationTokens),
|
||||
})
|
||||
|
|
@ -243,7 +226,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
inChars += len(tc.Name) + len(tc.Arguments)
|
||||
}
|
||||
}
|
||||
emit(cfg, Event{
|
||||
emit(tc, Event{
|
||||
Role: "usage",
|
||||
Content: fmt.Sprintf("%d %d 1 0", inChars/4, content.Len()/4),
|
||||
})
|
||||
|
|
@ -259,7 +242,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
if ctx.Err() != nil {
|
||||
// User pause: record partial state and return.
|
||||
if hadReasoning {
|
||||
emit(cfg, Event{Role: "reasoning", Content: "\n</think>\n"})
|
||||
emit(tc, Event{Role: "reasoning", Content: "\n</think>\n"})
|
||||
}
|
||||
msg := backend.Message{ID: responseID, Role: "assistant", Content: content.String(), Reasoning: reasoning.String(), ToolCalls: toolCalls}
|
||||
var results []toolResult
|
||||
|
|
@ -272,7 +255,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
})
|
||||
}
|
||||
state.update(msg, results)
|
||||
cfg.Save()
|
||||
tc.Save()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
|
|
@ -281,10 +264,10 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
return fmt.Errorf("step %d: stream dropped (no more retries)", step)
|
||||
}
|
||||
if hadReasoning {
|
||||
emit(cfg, Event{Role: "reasoning", Content: "\n</think>\n"})
|
||||
emit(tc, Event{Role: "reasoning", Content: "\n</think>\n"})
|
||||
}
|
||||
wait := streamDropBaseDelay << attempt
|
||||
if err := retryCountdown(ctx, cfg, wait); err != nil {
|
||||
if err := retryCountdown(ctx, tc, wait); err != nil {
|
||||
return fmt.Errorf("step %d: %w", step, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -299,8 +282,8 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
// Parse text-based tool calls: models that don't support the function
|
||||
// calling API emit tool invocations as plain text (e.g.
|
||||
// "file_read: args=[...]" (text-based tool call syntax)). Parse these into proper ToolCall structs.
|
||||
if len(toolCalls) == 0 && len(cfg.Tools) > 0 {
|
||||
if parsed := parseTextToolCalls(content.String(), cfg.Tools); len(parsed) > 0 {
|
||||
if len(toolCalls) == 0 && len(rt.Tools) > 0 {
|
||||
if parsed := parseTextToolCalls(content.String(), rt.Tools); len(parsed) > 0 {
|
||||
toolCalls = parsed
|
||||
}
|
||||
}
|
||||
|
|
@ -310,10 +293,10 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
results := make([]toolResult, 0, len(toolCalls))
|
||||
interrupted := false
|
||||
|
||||
cancelledResult := func(tc backend.ToolCall) toolResult {
|
||||
cancelledResult := func(call backend.ToolCall) toolResult {
|
||||
return toolResult{
|
||||
ToolCallID: tc.ID,
|
||||
Name: tc.Name,
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Content: `{"status":"cancelled","error":"interrupted"}`,
|
||||
IsError: true,
|
||||
}
|
||||
|
|
@ -321,24 +304,24 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
|
||||
// execOne runs a single tool call end-to-end and reports whether the
|
||||
// context was cancelled during execution.
|
||||
execOne := func(tc backend.ToolCall) (toolResult, bool) {
|
||||
if tc.Name == "" {
|
||||
return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: "error: empty tool name", IsError: true}, false
|
||||
execOne := func(call backend.ToolCall) (toolResult, bool) {
|
||||
if call.Name == "" {
|
||||
return toolResult{ToolCallID: call.ID, Name: call.Name, Content: "error: empty tool name", IsError: true}, false
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
cr := cancelledResult(tc)
|
||||
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: cr.Content, OutputFormat: toolOutputFormat(cfg, tc.Name)})
|
||||
cr := cancelledResult(call)
|
||||
emit(tc, Event{Role: "tool", Name: call.Name, Content: cr.Content, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
return cr, true
|
||||
}
|
||||
emit(cfg, Event{Role: "call", Name: tc.Name, Content: string(tc.Arguments)})
|
||||
readSafe := cfg.ClassifyTool != nil && cfg.ClassifyTool(tc.Name)
|
||||
emit(tc, Event{Role: "call", Name: call.Name, Content: string(call.Arguments)})
|
||||
readSafe := rt.ClassifyTool != nil && rt.ClassifyTool(call.Name)
|
||||
if readSafe {
|
||||
key := tc.Name + "\x00" + string(tc.Arguments)
|
||||
key := call.Name + "\x00" + string(call.Arguments)
|
||||
if v, ok := resultCache.Load(key); ok {
|
||||
entry := v.(cachedResult)
|
||||
if cacheValid(entry, tc.Arguments) {
|
||||
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: entry.Result, OutputFormat: toolOutputFormat(cfg, tc.Name)})
|
||||
return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: entry.Result}, false
|
||||
if cacheValid(entry, call.Arguments) {
|
||||
emit(tc, Event{Role: "tool", Name: call.Name, Content: entry.Result, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
return toolResult{ToolCallID: call.ID, Name: call.Name, Content: entry.Result}, false
|
||||
}
|
||||
// Stale — remove and re-execute.
|
||||
resultCache.Delete(key)
|
||||
|
|
@ -348,14 +331,14 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
var resultBlocks []backend.ContentBlock
|
||||
var isErr bool
|
||||
streamed := false
|
||||
if cfg.Exec != nil {
|
||||
if rt.Exec != nil {
|
||||
streamBytes := 0
|
||||
streamCtx := toolsrv.WithOutputStream(ctx, func(data string) {
|
||||
if streamBytes >= defaultToolResultMaxBytes {
|
||||
return // already at ceiling, drop further chunks
|
||||
}
|
||||
if !streamed {
|
||||
emit(cfg, Event{Role: "tool", Name: tc.Name, OutputFormat: toolOutputFormat(cfg, tc.Name)})
|
||||
emit(tc, Event{Role: "tool", Name: call.Name, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
streamed = true
|
||||
}
|
||||
streamBytes += len(data)
|
||||
|
|
@ -364,20 +347,20 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
excess := streamBytes - defaultToolResultMaxBytes
|
||||
data = data[:len(data)-excess]
|
||||
}
|
||||
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: data, OutputFormat: toolOutputFormat(cfg, tc.Name)})
|
||||
emit(tc, Event{Role: "tool", Name: call.Name, Content: data, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
})
|
||||
out, blocks, err := cfg.Exec(streamCtx, tc.Name, tc.Arguments)
|
||||
out, blocks, err := rt.Exec(streamCtx, call.Name, call.Arguments)
|
||||
if err != nil {
|
||||
isErr = true
|
||||
if ctx.Err() != nil {
|
||||
result = "error: tool execution interrupted by user"
|
||||
if cfg.PopInject != nil {
|
||||
if injected := cfg.PopInject(); injected != "" {
|
||||
if tc.PopInject != nil {
|
||||
if injected := tc.PopInject(); injected != "" {
|
||||
result += "\n\n<system-user-interruption>\n" + injected + "\n</system-user-interruption>"
|
||||
}
|
||||
}
|
||||
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: result, OutputFormat: toolOutputFormat(cfg, tc.Name)})
|
||||
return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: result, IsError: true}, true
|
||||
emit(tc, Event{Role: "tool", Name: call.Name, Content: result, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
return toolResult{ToolCallID: call.ID, Name: call.Name, Content: result, IsError: true}, true
|
||||
}
|
||||
var rlErr *toolsrv.RateLimitedError
|
||||
if errors.As(err, &rlErr) {
|
||||
|
|
@ -396,8 +379,8 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
// Accumulate suffix text (user-interruptions, truncation hints)
|
||||
// that must be emitted after streaming completes.
|
||||
var suffix string
|
||||
if cfg.PopInject != nil {
|
||||
if injected := cfg.PopInject(); injected != "" {
|
||||
if tc.PopInject != nil {
|
||||
if injected := tc.PopInject(); injected != "" {
|
||||
suffix += "\n\n<system-user-interruption>\n" + injected + "\n</system-user-interruption>"
|
||||
}
|
||||
}
|
||||
|
|
@ -407,48 +390,48 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
orig := len(result)
|
||||
result = strings.ToValidUTF8(result[:defaultToolResultMaxBytes], "")
|
||||
suffix = fmt.Sprintf("\n\n[HARD LIMIT: %s output truncated — %d of %d bytes shown. This is a safety ceiling, not a semantic boundary.]",
|
||||
tc.Name, defaultToolResultMaxBytes, orig)
|
||||
call.Name, defaultToolResultMaxBytes, orig)
|
||||
result += suffix
|
||||
}
|
||||
if readSafe && !isErr {
|
||||
path := extractFilePath(tc.Arguments)
|
||||
path := extractFilePath(call.Arguments)
|
||||
mtime, size := fileStat(path)
|
||||
resultCache.Store(tc.Name+"\x00"+string(tc.Arguments), cachedResult{
|
||||
resultCache.Store(call.Name+"\x00"+string(call.Arguments), cachedResult{
|
||||
Result: result,
|
||||
ModTime: mtime,
|
||||
Size: size,
|
||||
})
|
||||
}
|
||||
if !streamed {
|
||||
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: result, OutputFormat: toolOutputFormat(cfg, tc.Name)})
|
||||
emit(tc, Event{Role: "tool", Name: call.Name, Content: result, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
} else {
|
||||
// Emit suffixes that were appended after streaming completed
|
||||
// so they appear at the end of the chat output.
|
||||
if suffix != "" {
|
||||
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: suffix, OutputFormat: toolOutputFormat(cfg, tc.Name)})
|
||||
emit(tc, Event{Role: "tool", Name: call.Name, Content: suffix, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
}
|
||||
// Signal the log handler to close the source fence and emit
|
||||
// [[[end]]]. Without this, streamingRole stays set and the
|
||||
// fence is only closed on the next role transition — or never,
|
||||
// if the agent turn ends or is interrupted.
|
||||
emit(cfg, Event{Role: "tool", Name: tc.Name, OutputFormat: toolOutputFormat(cfg, tc.Name)})
|
||||
emit(tc, Event{Role: "tool", Name: call.Name, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
}
|
||||
tier := TierHot
|
||||
if !isErr && cfg.ClassifyTier != nil {
|
||||
tier = cfg.ClassifyTier(tc.Name, tc.Arguments)
|
||||
if !isErr && rt.ClassifyTier != nil {
|
||||
tier = rt.ClassifyTier(call.Name, call.Arguments)
|
||||
}
|
||||
return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: result, ContentBlocks: resultBlocks, IsError: isErr, Tier: tier}, false
|
||||
return toolResult{ToolCallID: call.ID, Name: call.Name, Content: result, ContentBlocks: resultBlocks, IsError: isErr, Tier: tier}, false
|
||||
}
|
||||
|
||||
isParallelSafe := func(name string) bool {
|
||||
return name != "" && cfg.ClassifyTool != nil && cfg.ClassifyTool(name)
|
||||
return name != "" && rt.ClassifyTool != nil && rt.ClassifyTool(name)
|
||||
}
|
||||
|
||||
for i := 0; i < len(toolCalls) && !interrupted; {
|
||||
if ctx.Err() != nil {
|
||||
for _, remaining := range toolCalls[i:] {
|
||||
cr := cancelledResult(remaining)
|
||||
emit(cfg, Event{Role: "tool", Name: remaining.Name, Content: cr.Content, OutputFormat: toolOutputFormat(cfg, remaining.Name)})
|
||||
emit(tc, Event{Role: "tool", Name: remaining.Name, Content: cr.Content, OutputFormat: toolOutputFormat(rt, remaining.Name)})
|
||||
results = append(results, cr)
|
||||
}
|
||||
interrupted = true
|
||||
|
|
@ -467,7 +450,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
fillCancelled := func(from int) {
|
||||
for _, remaining := range toolCalls[from:] {
|
||||
cr := cancelledResult(remaining)
|
||||
emit(cfg, Event{Role: "tool", Name: remaining.Name, Content: cr.Content, OutputFormat: toolOutputFormat(cfg, remaining.Name)})
|
||||
emit(tc, Event{Role: "tool", Name: remaining.Name, Content: cr.Content, OutputFormat: toolOutputFormat(rt, remaining.Name)})
|
||||
results = append(results, cr)
|
||||
}
|
||||
}
|
||||
|
|
@ -491,8 +474,8 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
batchInt := make([]bool, len(batch))
|
||||
var wg sync.WaitGroup
|
||||
uniqueResults := make([]inflightResult, len(batch))
|
||||
for k, tc := range batch {
|
||||
key := tc.Name + "\x00" + string(tc.Arguments)
|
||||
for k, call := range batch {
|
||||
key := call.Name + "\x00" + string(call.Arguments)
|
||||
if first, dup := inflight[key]; dup {
|
||||
// Will copy result from first occurrence after wg.Wait.
|
||||
batchResults[k] = toolResult{} // placeholder
|
||||
|
|
@ -501,29 +484,29 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
}
|
||||
inflight[key] = k
|
||||
wg.Add(1)
|
||||
go func(k int, tc backend.ToolCall) {
|
||||
go func(k int, call backend.ToolCall) {
|
||||
defer wg.Done()
|
||||
uniqueResults[k].tr, uniqueResults[k].wasInt = execOne(tc)
|
||||
}(k, tc)
|
||||
uniqueResults[k].tr, uniqueResults[k].wasInt = execOne(call)
|
||||
}(k, call)
|
||||
}
|
||||
wg.Wait()
|
||||
// Fill results: unique calls get their own result, duplicates copy from first.
|
||||
for k, tc := range batch {
|
||||
key := tc.Name + "\x00" + string(tc.Arguments)
|
||||
for k, call := range batch {
|
||||
key := call.Name + "\x00" + string(call.Arguments)
|
||||
first := inflight[key]
|
||||
if k == first {
|
||||
batchResults[k] = uniqueResults[k].tr
|
||||
batchInt[k] = uniqueResults[k].wasInt
|
||||
} else {
|
||||
batchResults[k] = toolResult{
|
||||
ToolCallID: tc.ID,
|
||||
Name: tc.Name,
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Content: uniqueResults[first].tr.Content,
|
||||
IsError: uniqueResults[first].tr.IsError,
|
||||
}
|
||||
batchInt[k] = uniqueResults[first].wasInt
|
||||
emit(cfg, Event{Role: "call", Name: tc.Name, Content: string(tc.Arguments)})
|
||||
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: batchResults[k].Content, OutputFormat: toolOutputFormat(cfg, tc.Name)})
|
||||
emit(tc, Event{Role: "call", Name: call.Name, Content: string(call.Arguments)})
|
||||
emit(tc, Event{Role: "tool", Name: call.Name, Content: batchResults[k].Content, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
}
|
||||
}
|
||||
// Add all batch results — model needs a result for every tool call.
|
||||
|
|
@ -540,11 +523,11 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
}
|
||||
|
||||
state.update(msg, results)
|
||||
cfg.Save()
|
||||
tc.Save()
|
||||
|
||||
// Auto-update TaskState from what actually happened this round.
|
||||
if ts := state.taskState(); ts != nil {
|
||||
inferTaskStateUpdate(ts, msg, results, cfg.ReadPlanStep)
|
||||
inferTaskStateUpdate(ts, msg, results, tc.ReadPlanStep)
|
||||
state.updateTaskState(*ts)
|
||||
}
|
||||
|
||||
|
|
@ -580,7 +563,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
consecutiveErrors = 0
|
||||
}
|
||||
if consecutiveErrors >= consecutiveErrorHardLimit {
|
||||
emit(cfg, Event{Role: "error", Content: fmt.Sprintf("%d consecutive tool errors — aborting", consecutiveErrors)})
|
||||
emit(tc, Event{Role: "error", Content: fmt.Sprintf("%d consecutive tool errors — aborting", consecutiveErrors)})
|
||||
return fmt.Errorf("step %d: %d consecutive tool errors", step, consecutiveErrors)
|
||||
}
|
||||
if consecutiveErrors == consecutiveErrorSoftLimit {
|
||||
|
|
@ -658,9 +641,9 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
// This allows the agent to continue working without hitting the soft
|
||||
// step-budget guardrail, as long as it's making active progress (writing
|
||||
// files, renaming symbols, etc.) rather than looping on research.
|
||||
if cfg.ResetsCounter != nil {
|
||||
if rt.ResetsCounter != nil {
|
||||
for _, r := range results {
|
||||
if !r.IsError && cfg.ResetsCounter(r.Name) {
|
||||
if !r.IsError && rt.ResetsCounter(r.Name) {
|
||||
step = 0
|
||||
break
|
||||
}
|
||||
|
|
@ -670,18 +653,18 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
// Step budget warnings: nudge the agent at 50% and when exhausted.
|
||||
// These only fire for research-heavy loops — action tools (file_write,
|
||||
// file_edit, shell) reset the counter, so active progress never triggers.
|
||||
if cfg.MaxSteps > 0 {
|
||||
halfBudget := cfg.MaxSteps / 2
|
||||
if rt.MaxSteps > 0 {
|
||||
halfBudget := rt.MaxSteps / 2
|
||||
if step == halfBudget {
|
||||
state.update(backend.Message{
|
||||
Role: "user",
|
||||
Content: fmt.Sprintf("<system-step-budget-warning>\nYou have used %d/%d research steps without taking action. Consider making progress — write code, edit files, or run commands. Action tools reset this counter.\n</system-step-budget-warning>", step, cfg.MaxSteps),
|
||||
Content: fmt.Sprintf("<system-step-budget-warning>\nYou have used %d/%d research steps without taking action. Consider making progress — write code, edit files, or run commands. Action tools reset this counter.\n</system-step-budget-warning>", step, rt.MaxSteps),
|
||||
}, nil)
|
||||
} else if step >= cfg.MaxSteps-1 {
|
||||
emit(cfg, Event{Role: "maxsteps", Content: fmt.Sprintf("%d", step+1)})
|
||||
} else if step >= rt.MaxSteps-1 {
|
||||
emit(tc, Event{Role: "maxsteps", Content: fmt.Sprintf("%d", step+1)})
|
||||
state.update(backend.Message{
|
||||
Role: "user",
|
||||
Content: fmt.Sprintf("<system-step-budget-stop>\nStep budget exhausted (%d/%d steps used). Stop calling tools. Summarize what you have done and what remains, then stop.\n</system-step-budget-stop>", step+1, cfg.MaxSteps),
|
||||
Content: fmt.Sprintf("<system-step-budget-stop>\nStep budget exhausted (%d/%d steps used). Stop calling tools. Summarize what you have done and what remains, then stop.\n</system-step-budget-stop>", step+1, rt.MaxSteps),
|
||||
}, nil)
|
||||
break
|
||||
}
|
||||
|
|
@ -700,8 +683,8 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
}
|
||||
}
|
||||
|
||||
if cfg.AutoCompact != nil {
|
||||
cfg.AutoCompact(ctx)
|
||||
if tc.AutoCompact != nil {
|
||||
tc.AutoCompact(ctx)
|
||||
}
|
||||
|
||||
step++
|
||||
|
|
@ -710,9 +693,9 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func emit(cfg agentConfig, msg Event) {
|
||||
if cfg.Output != nil {
|
||||
cfg.Output(msg)
|
||||
func emit(tc TurnCtx, msg Event) {
|
||||
if tc.Output != nil {
|
||||
tc.Output(msg)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1070,7 +1053,7 @@ func transientWait(err error, attempt int) (time.Duration, bool) {
|
|||
return 0, false
|
||||
}
|
||||
|
||||
func retryCountdown(ctx context.Context, cfg agentConfig, wait time.Duration) error {
|
||||
func retryCountdown(ctx context.Context, tc TurnCtx, wait time.Duration) error {
|
||||
deadline := time.Now().Add(wait)
|
||||
for {
|
||||
remaining := time.Until(deadline)
|
||||
|
|
@ -1081,7 +1064,7 @@ func retryCountdown(ctx context.Context, cfg agentConfig, wait time.Duration) er
|
|||
h := totalSecs / 3600
|
||||
m := (totalSecs % 3600) / 60
|
||||
s := totalSecs % 60
|
||||
emit(cfg, Event{Role: "retry", Content: fmt.Sprintf("%02d:%02d:%02d", h, m, s)})
|
||||
emit(tc, Event{Role: "retry", Content: fmt.Sprintf("%02d:%02d:%02d", h, m, s)})
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
|
|
|
|||
|
|
@ -23,11 +23,10 @@ type Runtime struct {
|
|||
ResetsCounter func(string) bool // true if tool resets step counter
|
||||
GenParams backend.GenerationParams
|
||||
MaxSteps int
|
||||
// CfgBackend and CfgModel are the backend/model overrides from the agent
|
||||
// config JSON. Empty means no override. Applied by the caller after
|
||||
// BuildRuntime returns.
|
||||
CfgBackend string
|
||||
CfgModel string
|
||||
// toolRegistryRevision tracks the last-seen tool registry revision. When the
|
||||
// registry revision advances (after tool_load/unload), the next turn
|
||||
// will detect the mismatch and refetch.
|
||||
toolRegistryRevision uint64
|
||||
// CompactionModel overrides the model used for context compaction.
|
||||
// Resolved from: agent config > OLLIE_COMPACTION_MODEL env > per-backend default.
|
||||
CompactionModel string
|
||||
|
|
|
|||
|
|
@ -100,46 +100,35 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
|||
|
||||
ag.auditLog.Debug("turn: start input=%s session=%s", auditTruncate(input), ag.sessionID)
|
||||
|
||||
// Build per-turn agentConfig from the current runtime.
|
||||
// Refresh tool definitions from the tool server so dynamically loaded
|
||||
// tools (via tool_load) are available on the next turn without a rebuild.
|
||||
ti, listErr := ag.runtime.ToolServer.ListTools()
|
||||
if listErr != nil {
|
||||
ag.log.Error("tool server unavailable: %v", listErr)
|
||||
ag.emit(Event{Role: "error", Content: fmt.Sprintf("tool server unavailable: %v", listErr)})
|
||||
ag.SetState("idle")
|
||||
return ""
|
||||
}
|
||||
tools := toolInfosToBackend(ti)
|
||||
|
||||
// Build output format lookup from the fresh tool list. This ensures
|
||||
// dynamically loaded tools (via tool_load) have their outputFormat
|
||||
// available immediately, not just after a full runtime rebuild.
|
||||
outputFormats := make(map[string]string, len(ti))
|
||||
for _, info := range ti {
|
||||
outputFormats[info.Name] = info.OutputFormat
|
||||
}
|
||||
outputFormatFn := func(name string) string {
|
||||
return outputFormats[name]
|
||||
// Refresh tool definitions only when the registry has changed (tool_load/unload).
|
||||
// This avoids ListTools IPC + allocation on every turn in the common case.
|
||||
if ag.toolsNeedRefresh() {
|
||||
ti, listErr := ag.runtime.ToolServer.ListTools()
|
||||
if listErr != nil {
|
||||
ag.log.Error("tool server unavailable: %v", listErr)
|
||||
ag.emit(Event{Role: "error", Content: fmt.Sprintf("tool server unavailable: %v", listErr)})
|
||||
ag.SetState("idle")
|
||||
return ""
|
||||
}
|
||||
ag.runtime.Tools = toolInfosToBackend(ti)
|
||||
outputFormats := make(map[string]string, len(ti))
|
||||
for _, info := range ti {
|
||||
outputFormats[info.Name] = info.OutputFormat
|
||||
}
|
||||
ag.runtime.OutputFormat = func(name string) string {
|
||||
return outputFormats[name]
|
||||
}
|
||||
}
|
||||
|
||||
ag.cfg = agentConfig{
|
||||
Backend: ag.runtime.Backend,
|
||||
preamble: ag.runtime.Preamble,
|
||||
Output: ag.output,
|
||||
Tools: tools,
|
||||
Exec: ag.runtime.Exec,
|
||||
ClassifyTool: ag.runtime.ClassifyTool,
|
||||
OutputFormat: outputFormatFn,
|
||||
ClassifyTier: ag.runtime.ClassifyTier,
|
||||
ResetsCounter: ag.runtime.ResetsCounter,
|
||||
GenerationParams: ag.runtime.GenParams,
|
||||
MaxSteps: ag.runtime.MaxSteps,
|
||||
ReadPlanStep: ag.readPlanStep,
|
||||
turnCtx := TurnCtx{
|
||||
Output: ag.output,
|
||||
ReadPlanStep: ag.readPlanStep,
|
||||
Save: ag.save,
|
||||
ResultCache: &ag.resultCache,
|
||||
}
|
||||
|
||||
var replyBuf strings.Builder
|
||||
ag.cfg.Output = func(ev Event) {
|
||||
turnCtx.Output = func(ev Event) {
|
||||
switch ev.Role {
|
||||
case "assistant":
|
||||
replyBuf.WriteString(ev.Content)
|
||||
|
|
@ -176,18 +165,18 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
|||
}
|
||||
ag.emit(ev)
|
||||
}
|
||||
ag.cfg.PopInject = func() string {
|
||||
turnCtx.PopInject = func() string {
|
||||
if p := ag.pendingInject.Swap(nil); p != nil {
|
||||
return *p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
ag.cfg.IncrToolCallCount = func() int64 {
|
||||
turnCtx.IncrToolCallCount = func() int64 {
|
||||
return ag.toolCallCount.Add(1)
|
||||
}
|
||||
ag.cfg.Save = ag.save
|
||||
ag.cfg.ResultCache = &ag.resultCache
|
||||
ag.cfg.AutoCompact = func(ctx context.Context) {
|
||||
turnCtx.Save = ag.save
|
||||
turnCtx.ResultCache = &ag.resultCache
|
||||
turnCtx.AutoCompact = func(ctx context.Context) {
|
||||
if ctx.Err() != nil || ag.history == nil {
|
||||
return
|
||||
}
|
||||
|
|
@ -214,7 +203,7 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
|||
}
|
||||
ag.SetState("thinking")
|
||||
} else if warnLimit := ag.autoWarnLimit(ctx); warnLimit > 0 && tokens >= warnLimit && !ag.warnedContext {
|
||||
ctxLen := ag.cfg.Backend.ContextLength(ctx)
|
||||
ctxLen := ag.runtime.Backend.ContextLength(ctx)
|
||||
if ctxLen <= 0 {
|
||||
ctxLen = defaultContextLength
|
||||
}
|
||||
|
|
@ -252,7 +241,7 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
|||
err error
|
||||
)
|
||||
for {
|
||||
err = run(actCtx, ag.cfg, ag.history)
|
||||
err = run(actCtx, ag.runtime, turnCtx, ag.history)
|
||||
actCancel(nil)
|
||||
ag.currentAction.CompareAndSwap(handle, nil)
|
||||
|
||||
|
|
@ -307,8 +296,8 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
|||
}
|
||||
|
||||
if ag.history != nil {
|
||||
ag.history.recordTurnCost(ag.cfg.Backend.Model())
|
||||
appendUsageLog(ag.sessionID, ag.cfg.Backend.Name(), ag.cfg.Backend.Model(), ag.history)
|
||||
ag.history.recordTurnCost(ag.runtime.Backend.Model())
|
||||
appendUsageLog(ag.sessionID, ag.runtime.Backend.Name(), ag.runtime.Backend.Model(), ag.history)
|
||||
if ag.history.LastTurnCostUSD > 0 {
|
||||
ag.emit(Event{Role: "info", Content: fmt.Sprintf("costLast=$%.4f\n", ag.history.LastTurnCostUSD)})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -398,7 +398,7 @@ func CreateAgent(sessName string, args []string) (*agent.Agent, error) {
|
|||
// Create backend - use override if provided, otherwise use config or env default
|
||||
beName := backendName
|
||||
if beName == "" {
|
||||
beName = rt.CfgBackend
|
||||
beName = cfg.Backend
|
||||
}
|
||||
be, err := backend.NewWithName(beName)
|
||||
if err != nil {
|
||||
|
|
@ -409,8 +409,8 @@ func CreateAgent(sessName string, args []string) (*agent.Agent, error) {
|
|||
// Apply model override if provided
|
||||
if modelName != "" {
|
||||
rt.Backend.SetModel(modelName)
|
||||
} else if rt.CfgModel != "" {
|
||||
rt.Backend.SetModel(rt.CfgModel)
|
||||
} else if cfg.Model != "" {
|
||||
rt.Backend.SetModel(cfg.Model)
|
||||
}
|
||||
|
||||
// Create agent
|
||||
|
|
|
|||
|
|
@ -33,6 +33,15 @@ func (e *Server) SetOnToolsChanged(fn func(string)) {
|
|||
e.OnToolsChanged = fn
|
||||
}
|
||||
|
||||
// ToolRegistryRevision returns the current registry revision for this server's session.
|
||||
// Returns 0 if no registry is configured.
|
||||
func (e *Server) ToolRegistryRevision() uint64 {
|
||||
if e.toolRegistry == nil || e.sessionID == "" {
|
||||
return 0
|
||||
}
|
||||
return e.toolRegistry.Revision(e.sessionID)
|
||||
}
|
||||
|
||||
// ExecuteInSandbox runs a command in the sandbox. Exported for builtin tools.
|
||||
func (e *Server) ExecuteInSandbox(ctx context.Context, cmd, language string, timeout int, sandbox string, trusted bool, stdin string, detach bool) (string, error) {
|
||||
return e.executeWithStdin(ctx, cmd, language, timeout, sandbox, trusted, stdin, detach)
|
||||
|
|
|
|||
Loading…
Reference in New Issue