Skip to content

Commit 33877b3

Browse files
committed
fix agent tool handling and deletion
1 parent 48ee0b3 commit 33877b3

9 files changed

Lines changed: 316 additions & 38 deletions

File tree

internal/agent/loop.go

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,14 @@ func (a *Agent) meterTokens(ctx context.Context, sessionKey string, u provider.U
719719
// HandleMessage path. Providers that don't actually stream still work
720720
// — they just deliver one big chunk on Done.
721721
func (a *Agent) streamChatToResponse(ctx context.Context, messages []provider.Message, tools []provider.Tool) (*provider.Response, error) {
722+
return a.streamChatToResponseWithOptions(ctx, messages, tools, true)
723+
}
724+
725+
func (a *Agent) streamChatToResponseQuiet(ctx context.Context, messages []provider.Message, tools []provider.Tool) (*provider.Response, error) {
726+
return a.streamChatToResponseWithOptions(ctx, messages, tools, false)
727+
}
728+
729+
func (a *Agent) streamChatToResponseWithOptions(ctx context.Context, messages []provider.Message, tools []provider.Tool, emitDeltas bool) (*provider.Response, error) {
722730
sr, err := a.provider.ChatStream(ctx, messages, tools, a.model, a.maxTokens, a.temperature)
723731
if err != nil {
724732
return nil, err
@@ -738,15 +746,17 @@ func (a *Agent) streamChatToResponse(ctx context.Context, messages []provider.Me
738746
}
739747
if chunk.Content != "" {
740748
contentBuilder.WriteString(chunk.Content)
741-
// Push the incremental delta. The web chat panel
742-
// appends it to the bubble in progress; consumers
743-
// that only know about the legacy `content` event
744-
// ignore unknown types and rely on the final
745-
// emit (caller's responsibility) instead.
746-
emitEvent(ctx, ChatEvent{
747-
Type: "content_delta",
748-
Data: map[string]any{"delta": chunk.Content},
749-
})
749+
if emitDeltas {
750+
// Push the incremental delta. The web chat panel
751+
// appends it to the bubble in progress; consumers
752+
// that only know about the legacy `content` event
753+
// ignore unknown types and rely on the final
754+
// emit (caller's responsibility) instead.
755+
emitEvent(ctx, ChatEvent{
756+
Type: "content_delta",
757+
Data: map[string]any{"delta": chunk.Content},
758+
})
759+
}
750760
}
751761
if chunk.Done {
752762
toolCalls = chunk.ToolCalls
@@ -2325,9 +2335,9 @@ func (a *Agent) HandleMessage(ctx context.Context, msg bus.InboundMessage) strin
23252335
finalMessages = privacy.ScrubMessages(finalMessages)
23262336
}
23272337
finalContent := ""
2328-
finalResp, finalErr := a.streamChatToResponse(ctx, finalMessages, nil)
2338+
finalResp, finalErr := a.streamChatToResponseQuiet(ctx, finalMessages, nil)
23292339
if finalErr == nil {
2330-
finalContent = finalResp.Content
2340+
finalContent = scrubLeakedToolCallContent(finalResp.Content)
23312341
a.meterTokens(ctx, sess.Key(), finalResp.Usage, 0)
23322342
}
23332343
if finalContent == "" {

internal/agent/prompt_modules.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,7 +654,19 @@ tool. camoufox-cli is the ONLY browser tool in this sandbox. Do NOT run
654654
## Web tools
655655
656656
- If the user gives you a URL, web_fetch it directly — don't web_search first.
657+
- If the user asks you to search/find/look up something, or asks for nearby
658+
places, events, news, reviews, weather, prices, availability, "latest",
659+
"recent", or anything else where no exact page URL was provided, call
660+
web_search FIRST. Treat Chinese phrasing like "搜一下", "找一下",
661+
"附近有什么", "有什么活动", "最近", and "最新" as search intent.
662+
- Do NOT web_fetch search result pages such as google.com/search,
663+
bing.com/search, baidu.com/s, or duckduckgo.com/?q=. Put the query into
664+
web_search instead, then web_fetch only a concrete result URL if needed.
657665
- If web_search snippets already answer the question, reply from those — don't fetch the page.
666+
- If web_fetch on a concrete page fails with 401/403/429, captcha,
667+
anti-bot, "enable JavaScript", or an empty/blocked page, fall back to
668+
camoufox-cli in the sandbox: load_skill("camoufox-cli"), open the same
669+
URL, wait for the page, and extract/screenshot the visible content.
658670
659671
## Forbidden actions
660672
@@ -842,6 +854,15 @@ Four failure modes that cost rounds:
842854
the https scheme and fetch the root. Skipping straight to fetch
843855
saves a full round and is what the user expected when they handed
844856
you the address.
857+
For search intent — "search/find/look up", "nearby", "events",
858+
"news", "reviews", "weather", "prices", "availability", "latest",
859+
"recent", or Chinese phrasing like "搜一下", "找一下",
860+
"附近有什么", "有什么活动", "最近", "最新" — call web_search FIRST
861+
unless the user gave you an exact page URL. Do NOT synthesize a
862+
search-engine URL and web_fetch it. Search result URLs such as
863+
google.com/search, bing.com/search, baidu.com/s, and
864+
duckduckgo.com/?q= are not sources; they are failed web_search
865+
substitutes.
845866
For URLs you DON't have — questions where the user describes a
846867
page in natural language ("the latest Tencent earnings report") —
847868
call web_search first to discover the URL, then web_fetch it.
@@ -854,6 +875,14 @@ Four failure modes that cost rounds:
854875
your remaining budget — the runtime refuses retries of the same
855876
failed URL within this turn, so swap source, not just the path.
856877
878+
Browser fallback: if web_fetch fails on a concrete, non-search-result
879+
page with 401/403/429, captcha, anti-bot, "enable JavaScript", or an
880+
empty/blocked body, do NOT keep retrying web_fetch. Load the
881+
camoufox-cli skill and use the sandbox browser against the SAME URL
882+
(open → wait → extract visible text or screenshot). This fallback is
883+
for browser-required pages only; if the URL itself was guessed or is
884+
a search results page, go back to web_search instead.
885+
857886
2. **Stop when you have enough.** If web_search snippets already
858887
contain the specific facts the user asked about (dates, numbers,
859888
names, yes/no answer), synthesize the answer FROM the snippets and

internal/agent/tool_recovery.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,19 @@ func (a *Agent) maybeRecoverToolCalls(resp *provider.Response) {
5656
resp.RawAssistant = nil
5757
}
5858

59+
// scrubLeakedToolCallContent removes visible DSML / tool-call markup from
60+
// assistant text that is going to be shown directly to the user. It is used
61+
// on terminal synthesis paths where we do NOT want to execute any newly
62+
// recovered calls (for example after the iteration cap has already been hit),
63+
// but we still must not render raw `< | | DSML | | invoke ...>` garbage.
64+
func scrubLeakedToolCallContent(content string) string {
65+
recovered, residual := recoverToolCallsFromContent(content)
66+
if len(recovered) > 0 || residual != content {
67+
return strings.TrimSpace(residual)
68+
}
69+
return content
70+
}
71+
5972
// recoverToolCallsFromContent parses tool-call attempts that some open-
6073
// source models (DeepSeek, Qwen variants) emit as XML in the assistant
6174
// `content` field instead of using the OpenAI Chat Completions
@@ -195,9 +208,10 @@ var tagLeakHintRE = regexp.MustCompile(`<invoke|<\s*/?\s*[||]`)
195208
// it), so we let either noise group consume it.
196209
//
197210
// Captures:
198-
// $1 = optional `/` for closing tags
199-
// $2 = real tag name (invoke / parameter / tool_calls / function_calls / DSML)
200-
// $3 = attributes that follow the tag name (e.g. ` name="exec"`)
211+
//
212+
// $1 = optional `/` for closing tags
213+
// $2 = real tag name (invoke / parameter / tool_calls / function_calls / DSML)
214+
// $3 = attributes that follow the tag name (e.g. ` name="exec"`)
201215
//
202216
// Replacement `<${1}${2}${3}>` reconstructs `<invoke name="exec">` etc.
203217
// Clean inputs like `<DSML>` / `<invoke name="x">` round-trip unchanged

internal/agent/tool_recovery_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,25 @@ func TestRecoverToolCallsFromContent(t *testing.T) {
184184
})
185185
}
186186
}
187+
188+
func TestScrubLeakedToolCallContentForDisplay(t *testing.T) {
189+
in := `< | | DSML | | tool_calls>
190+
< | | DSML | | invoke name="exec">
191+
< | | DSML | | parameter name="command" string="true">curl -sL "https://www.baidu.com/s?wd=idoubi"</ | | DSML | | parameter>
192+
< | | DSML | | parameter name="timeout" string="false">15</ | | DSML | | parameter>
193+
</ | | DSML | | invoke>
194+
</ | | DSML | | tool_calls>`
195+
if got := scrubLeakedToolCallContent(in); got != "" {
196+
t.Fatalf("scrubbed display content = %q; want empty", got)
197+
}
198+
199+
withPreamble := "I'll check. " + in
200+
got := scrubLeakedToolCallContent(withPreamble)
201+
if strings.Contains(got, "DSML") || strings.Contains(got, "tool_calls") ||
202+
strings.Contains(got, "invoke") || strings.Contains(got, "parameter") {
203+
t.Fatalf("scrubbed display content still leaks tool markup: %q", got)
204+
}
205+
if strings.TrimSpace(got) != "I'll check." {
206+
t.Fatalf("scrubbed display content = %q; want preamble only", got)
207+
}
208+
}

internal/agent/tools/web_fetch.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,23 @@ func init() {
109109
}
110110

111111
const webFetchDescription = "Fetch a single known URL and return its plain text. " +
112+
"Use this only after you already know the exact target page URL. " +
112113
"If the user's message itself contains a URL or bare domain " +
113114
"(e.g. 'idoubi.ai', 'https://example.com/cv'), fetch THAT URL " +
114115
"directly — prepend https:// for bare domains — instead of " +
115-
"running web_search first. DO NOT guess URLs from memory: " +
116+
"running web_search first. For search intent like 'search/find/look up', " +
117+
"'nearby', 'events', 'news', 'reviews', 'weather', 'latest', or any request " +
118+
"where you do not already have a concrete page URL, call web_search first. " +
119+
"Never web_fetch search result pages such as google.com/search, bing.com/search, " +
120+
"baidu.com/s, or duckduckgo.com/?q=; use web_search for those queries instead. " +
121+
"DO NOT guess URLs from memory: " +
116122
"your training data has stale paths and you will burn rounds " +
117123
"on 404s. When the user described a page in natural language " +
118124
"with no URL, run web_search first to discover the URL, then " +
119-
"web_fetch that exact URL. If web_search isn't available, " +
125+
"web_fetch that exact URL. If web_fetch on a concrete page fails with " +
126+
"401/403/429, captcha, anti-bot, or JavaScript-required output, use the " +
127+
"camoufox-cli skill in the sandbox against the same URL instead of retrying " +
128+
"web_fetch. If web_search isn't available, " +
120129
"prefer well-known stable hosts (en.wikipedia.org, github.com), " +
121130
"not date-stamped article URLs. A URL that returned 4xx/5xx " +
122131
"earlier in this turn will be refused if you retry it."

internal/store/database.go

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ func (d *DBStore) Migrate(ctx context.Context) error {
182182
//
183183
// Empty default + partial indexes preserve existing query plans for
184184
// rows written before this column existed. Readers that want the
185-
// chatter should COALESCE(NULLIF(chatter_user_id,''), user_id) — the
185+
// chatter should COALESCE(NULLIF(chatter_user_id,), user_id) — the
186186
// fallback is exactly right for the web channel (user_id was already
187187
// the chatter there) and matches the pre-fix behavior on IM (where
188188
// every chatter was mis-attributed to the channel owner anyway).
@@ -222,7 +222,7 @@ func (d *DBStore) migrateSessionsAddChatterUserID(ctx context.Context) error {
222222
}
223223

224224
// migrateAgentGoalsAddRouting retrofits channel/account_id/chat_id/
225-
// project_id onto legacy agent_goals tables. All four default to ''
225+
// project_id onto legacy agent_goals tables. All four default to
226226
// — pre-existing rows had no continuation infrastructure attached
227227
// anyway, so the empty value just means "no routing recorded; can't
228228
// auto-continue this goal" and TryFireContinuation bails safely.
@@ -523,7 +523,7 @@ func (d *DBStore) migrateConfigsAddScopeColumn(ctx context.Context) error {
523523
// (user_id, agent_id) into a single lookup key: whichever is non-empty
524524
// wins (they're mutually exclusive for provider/setting rows — the only
525525
// kinds that remain in configs now that channels have their own table).
526-
// System rows get scope_id=''.
526+
// System rows get scope_id=.
527527
//
528528
// Idempotent: skips the ALTER if the column already exists and only
529529
// backfills rows where scope_id is still empty.
@@ -2002,7 +2002,8 @@ func (d *DBStore) DeleteUser(ctx context.Context, id string) error {
20022002
return err
20032003
}
20042004
if _, err := tx.ExecContext(ctx,
2005-
fmt.Sprintf("DELETE FROM configs WHERE agent_id = %s", d.ph(1)), aid); err != nil {
2005+
fmt.Sprintf("DELETE FROM configs WHERE scope_id = %s OR scope_id LIKE %s", d.ph(1), d.ph(2)),
2006+
aid, "%/"+aid); err != nil {
20062007
return err
20072008
}
20082009
}
@@ -2272,7 +2273,16 @@ func (d *DBStore) DeleteAgent(ctx context.Context, agentID string) error {
22722273
return err
22732274
}
22742275
defer tx.Rollback()
2275-
for _, t := range []string{"agent_files", "sessions", "session_messages", "session_events", "cron_jobs"} {
2276+
for _, t := range []string{
2277+
"agent_files",
2278+
"sessions",
2279+
"session_messages",
2280+
"session_events",
2281+
"cron_jobs",
2282+
"projects",
2283+
"project_runtimes",
2284+
"agent_goals",
2285+
} {
22762286
if _, err := tx.ExecContext(ctx,
22772287
fmt.Sprintf(`DELETE FROM %s WHERE agent_id = %s`, t, d.ph(1)), agentID); err != nil {
22782288
return err
@@ -2282,12 +2292,11 @@ func (d *DBStore) DeleteAgent(ctx context.Context, agentID string) error {
22822292
fmt.Sprintf(`DELETE FROM apikey_agents WHERE agent_id = %s`, d.ph(1)), agentID); err != nil {
22832293
return err
22842294
}
2285-
// Drop every config row pointing at this agent — owner's official
2286-
// rows (user_id='', agent_id=X), agent owner's per-agent overrides
2287-
// (user_id=owner, agent_id=X), and any non-owner per-agent
2288-
// overrides (user_id=other, agent_id=X).
2295+
// Drop every config row pointing at this agent — official agent rows
2296+
// (scope_id=X) and per-user agent overrides (scope_id=user/X).
22892297
if _, err := tx.ExecContext(ctx,
2290-
fmt.Sprintf(`DELETE FROM configs WHERE agent_id = %s`, d.ph(1)), agentID); err != nil {
2298+
fmt.Sprintf(`DELETE FROM configs WHERE scope_id = %s OR scope_id LIKE %s`, d.ph(1), d.ph(2)),
2299+
agentID, "%/"+agentID); err != nil {
22912300
return err
22922301
}
22932302
if _, err := tx.ExecContext(ctx,
@@ -2818,7 +2827,7 @@ func (d *DBStore) ListSessionMessages(ctx context.Context, userID, agentID, sess
28182827
//
28192828
// Filter is strictly on chatter_user_id (no fallback to user_id). Old
28202829
// rows written before the chatter_user_id column existed have it set
2821-
// to '' and are not counted; those predate per-chatter resolution and
2830+
// to and are not counted; those predate per-chatter resolution and
28222831
// folding them in would over-count (they're keyed by channel owner,
28232832
// not the actual chatter). New conversations write chatter_user_id
28242833
// correctly so this is only a concern for sessions migrated from
@@ -3385,9 +3394,9 @@ func (d *DBStore) migrateChannelsFromConfigs(ctx context.Context) error {
33853394
// Each config row may have multiple accounts in its data JSON.
33863395
// Extract them and create one channel row per account.
33873396
var cc struct {
3388-
BotToken string `json:"botToken"`
3389-
BaseURL string `json:"baseUrl"`
3390-
Accounts map[string]json.RawMessage `json:"accounts"`
3397+
BotToken string `json:"botToken"`
3398+
BaseURL string `json:"baseUrl"`
3399+
Accounts map[string]json.RawMessage `json:"accounts"`
33913400
}
33923401
if blob, merr := json.Marshal(cfg.Data); merr == nil {
33933402
_ = json.Unmarshal(blob, &cc)

0 commit comments

Comments
 (0)