Skip to content

feat(gateway): add optional role param to chat.inject RPC#60706

Closed
donut-wenzhang wants to merge 34 commits into
openclaw:mainfrom
DonutLabs-ai:feat/chat-inject-role-param
Closed

feat(gateway): add optional role param to chat.inject RPC#60706
donut-wenzhang wants to merge 34 commits into
openclaw:mainfrom
DonutLabs-ai:feat/chat-inject-role-param

Conversation

@donut-wenzhang

Copy link
Copy Markdown

Summary

Add optional role parameter to chat.inject RPC, allowing callers to inject messages with role=user (in addition to the existing role=assistant default).

Motivation: After d0-cli hot-update, we inject a message telling the bot to re-run d0 list. With the previous hardcoded role=assistant, the bot ignored it (doesn't treat its own past messages as instructions). With role=user, the bot executes it as a user command.

Changes:

  • logs-chat.ts: Add optional role to ChatInjectParamsSchema ("assistant" | "user")
  • chat.ts: Pass role through handler → appendAssistantTranscriptMessage
  • chat-transcript-inject.ts: Use role param (default "assistant"); omit assistant-specific fields (stopReason, usage, api, provider, model) for user-role messages

Backward compatible — existing callers without role param get the same behavior.

Test plan

  • Existing chat.inject calls without role still produce role=assistant messages
  • chat.inject with role=user produces user-role messages in session JSONL
  • Bot treats injected role=user message as instruction and executes it

arucil and others added 30 commits March 12, 2026 09:32
Allow overriding the Telegram Bot API base URL via `channels.telegram.apiRoot`,
enabling custom Bot API servers and bot routing proxies.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
…ons (#1)

Allow hook API callers to pass sessionTarget="reuse" so that multiple
hook invocations with the same sessionKey share the same agent session,
enabling multi-turn conversation context retention.

Previously sessionTarget was hardcoded to "isolated" for all hook calls,
meaning every invocation created a brand new session regardless of
sessionKey. With "reuse", the existing session freshness evaluation is
used to determine whether to continue an existing session.

Changes:
- Add HookSessionTarget type and sessionTarget field to hook payload
- Pass sessionTarget through to CronJob creation (default: "isolated")
- Add "reuse" to CronSessionTarget union type across all validators
- Add "reuse" to CronSessionTargetSchema for gateway cron API
- Add assertSupportedJobSpec validation for "reuse" + "agentTurn"
- Allow channel delivery for "reuse" sessions (same as "isolated")

Co-authored-by: Claude Opus 4.6 <[email protected]>
When skills/ changes on main, triggers openclaw-updated repository_dispatch
to DonutLabs-ai/donut-backend, which builds a skills tarball and hot-updates
running D0 Gateway containers without restart.

Co-authored-by: Regison <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
…spaceFiles

Allow operators to extend the built-in workspace file whitelist (bootstrap + memory files)
by setting agents.defaults.extraWorkspaceFiles in openclaw.json.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
When senderId or senderUsername is available in the agent run context,
inject X-TG-User-Id and X-TG-Username headers into outgoing LLM
requests. This allows downstream proxies to attribute token usage to
individual end users (e.g. Telegram senders).

Co-authored-by: Claude Opus 4.6 <[email protected]>
Add two new streamFn wrappers that inject metadata into every outbound
LLM request so the proxy can apply quota and usage tracking:

- trigger_source: indicates what initiated the agent run ("user", "cron",
  "heartbeat"), derived from the existing PluginHookAgentContext.trigger
  field that is already set by cron jobs and user message handlers.

- turn_id: a per-turn UUID generated before activeSession.prompt(), shared
  across all LLM calls within a single agent turn so the proxy can
  correlate multi-call agent loops back to one user message.

Both values are injected via HTTP headers (X-Trigger-Source, X-Turn-Id)
and request body fields (trigger_source, turn_id) for maximum proxy
compatibility regardless of transport.

Co-authored-by: Regison <[email protected]>
Merged via squash.

Prepared head SHA: e4183b3
Co-authored-by: Sid-Qin <[email protected]>
Co-authored-by: shakkernerd <[email protected]>
Reviewed-by: @shakkernerd
* feat(telegram): add apiRoot config passthrough to grammY

Allow overriding the Telegram Bot API base URL via `channels.telegram.apiRoot`,
enabling custom Bot API servers and bot routing proxies.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* docs(telegram): document apiRoot config option

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(hooks): add sessionTarget="reuse" for multi-turn hook conversations (#1)

Allow hook API callers to pass sessionTarget="reuse" so that multiple
hook invocations with the same sessionKey share the same agent session,
enabling multi-turn conversation context retention.

Previously sessionTarget was hardcoded to "isolated" for all hook calls,
meaning every invocation created a brand new session regardless of
sessionKey. With "reuse", the existing session freshness evaluation is
used to determine whether to continue an existing session.

Changes:
- Add HookSessionTarget type and sessionTarget field to hook payload
- Pass sessionTarget through to CronJob creation (default: "isolated")
- Add "reuse" to CronSessionTarget union type across all validators
- Add "reuse" to CronSessionTargetSchema for gateway cron API
- Add assertSupportedJobSpec validation for "reuse" + "agentTurn"
- Allow channel delivery for "reuse" sessions (same as "isolated")

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat(skills): scan sub-skills from index.md for hierarchical display

Add scanSubSkills() to skills-status that reads subdirectory index.md
files under each skill's baseDir. Router skills (SKILL.md) now include
a children array in the skills.status RPC response, enabling the HIL
Dashboard to display skills in a categorized hierarchy.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: arucil <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
* feat(agents): inject feature_context and skill_names into LLM proxy requests

Add onPayload wrapper that automatically classifies each LLM API call:
- chat_response: user message triggered the call
- tool_result: processing tool execution results
- system: fallback for unclassifiable calls

Also injects active skill_names array for per-skill token attribution.
Both fields are read by the donut-backend LLM proxy for analytics.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor(agents): remove client-side feature_context, keep skill_names only

feature_context is now inferred server-side by the donut-backend LLM
proxy from the messages array. Remove the client-side inference logic
and rename the wrapper to createSkillNamesWrapper since that is its
only remaining responsibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Regison <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
* feat: add triggerSource to GetReplyOptions

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: use triggerSource from opts in agent runner trigger assignment

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: read x-trigger-source header and attach to webhook update body

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: extract _triggerSource from grammy update in message context

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: thread triggerSource from context into replyOptions

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
buildSyntheticContext() creates a new object with only {message, me,
getFile}, discarding ctx.update. So _triggerSource on the update root
was always lost. Fix by attaching it to body.value.message and reading
from primaryCtx.message in the context builder.
…jection (#10)

The `createSkillNamesWrapper` was injecting `skill_names` into the LLM
request body via `onPayload`. When the gateway sends requests directly
to Anthropic (bypassing the LLM proxy), Anthropic rejects the unknown
body field with "Extra inputs are not permitted".

Switch to injecting via `X-Skill-Names` HTTP header instead, which is
safely ignored by strict APIs. The donut-backend LLM proxy will be
updated to read from this header (with body field as legacy fallback).

Co-authored-by: Claude Opus 4.6 <[email protected]>
…_id wrappers (#11)

Same issue as skill_names — strict APIs (Anthropic) reject unknown body
fields. Both wrappers already inject via HTTP headers (X-Trigger-Source,
X-Turn-Id), so the redundant onPayload body injection is removed.

Co-authored-by: Claude Opus 4.6 <[email protected]>
…sion (TES-642) (#12)

Cherry-pick upstream 7f2708a — fixes the core issue where
messagingToolSentTargets with missing provider/to params caused
shouldSuppressMessagingToolReplies to return false, leaking all
assistant text blocks to Telegram.

Key changes:
- resolveTargetProviderForComparison: falls back to current provider
  when target provider is empty or "message"
- targetsMatchForSuppression: Telegram-aware chatId+threadId matching
- mergeDeliveryContext: prevents cross-channel route field inheritance
- chat.ts: only inherit delivery routes for channel-scoped sessions

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
)

* fix: suppress block reply for text+toolCall mixed messages (TES-642)

When an assistant message contains both text and tool call blocks, the
text is intermediate reasoning (e.g. "Let me read the skill...") that
should NOT be forwarded to the user. Real user-facing replies go
through the messaging tool explicitly.

Add hasToolCall check in handleMessageEnd to skip block reply emission
when the message content includes toolCall or tool_use blocks.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: track message tool sends without explicit target for suppression (TES-642)

When the message tool is called without to/target (e.g. Telegram DMs
where there's only one chat), extractMessagingToolSend returned
undefined — so no target was tracked in messagingToolSentTargets.

This caused shouldSuppressMessagingToolReplies to return false (empty
targets array), allowing ALL assistant text blocks to leak as final
payloads to Telegram.

Fix:
- extractMessagingToolSend: return { to: "" } instead of undefined
  when to/target is missing
- shouldSuppressMessagingToolReplies: treat empty to as "matches
  origin target" since it means the message was implicitly sent to
  the originating conversation

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(telegram): delete draft preview on tool start to prevent reasoning leak (TES-642)

When the agent streams intermediate reasoning text ("Let me fetch
data..."), the Telegram draft preview shows it as a live-updating
message. Previously, these previews were only archived on the next
assistant message start and deleted in the finally block — meaning
users saw all intermediate thinking during the agent loop.

Now when tool execution starts, the onToolStart callback immediately
deletes the draft preview message. This is correct because:
- Text before a tool call is always intermediate reasoning
- The actual user-facing response comes via the message() tool
- The draft preview for reasoning text should never persist

Also made onToolStart always available (not gated on
statusReactionController) so the cleanup works even without reaction
support.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
…nchain

[codex] Gateway: inject donut runtime analytics IDs
…eded (#17)

* feat(heartbeat): add beforeRun gate to skip LLM calls when no work needed

Add a `beforeRun` config option that executes a user-supplied script before
each heartbeat LLM call. If the script exits non-zero, the heartbeat is
skipped with zero token cost. Bypassed for cron, exec-event, and
wake-triggered heartbeats to ensure time-sensitive events always run.

Made-with: Cursor

* fix(heartbeat): use async execFile and improve beforeRun error handling

- Replace execFileSync with async execFile to avoid blocking the event loop
- Distinguish timeout, ENOENT, and intentional rejection in error logging
- Strengthen workspace dir test assertion to verify actual path
- Add test for non-existent script path (ENOENT)

Made-with: Cursor
* feat(cron): add beforeRun gate to skip LLM for cron jobs

Add a `beforeRun` field to CronJobBase that accepts a script path.
The script runs before the LLM session — exit 0 proceeds, non-zero
skips the entire LLM call (zero token cost). This allows cron jobs
to run data scripts deterministically and only invoke the LLM when
there is meaningful data to format and deliver.

Changes:
- types-shared.ts: add optional `beforeRun` field
- normalize.ts: trim/validate beforeRun during normalization
- service/jobs.ts: handle beforeRun in createJob and applyJobPatch
- service/timer.ts: execute beforeRun gate before runIsolatedAgentJob
  with 120s timeout, tilde expansion, and structured logging

Made-with: Cursor

* test(cron): add unit tests for beforeRun gate

10 test cases covering:
- Gate script exit 0 → LLM proceeds
- Gate script exit 1/2 → LLM skipped
- Missing script → LLM skipped (logged as rejection)
- No beforeRun configured → normal execution
- Job id passed as $1 argument
- CRON_JOB_ID env var set
- Logging: pass/reject/not-found

Made-with: Cursor

* fix(cron): use err.code instead of err.status for exit code logging

execFile error uses `code` (number for exit codes, string for system
errors like ENOENT), not `status`. The previous code always logged
undefined for the exit code in gate rejection messages.

Made-with: Cursor

* fix(cron): normalize beforeRun field during store deserialization

Add beforeRun normalization in store.ts ensureLoaded loop,
consistent with how description and sessionKey are handled.
Prevents malformed beforeRun values (whitespace, wrong type)
from persisting in the store file.

Made-with: Cursor
…tlabs

[codex] Report TG tool lifecycle to backend
The cron system fully supports beforeRun (storage, execution, update
patching) but CronAddParamsSchema was missing the field. Because
additionalProperties is false, passing beforeRun to cron.add was
rejected with "unexpected property 'beforeRun'".

This forced a workaround of add + update to set the gate, which is
error-prone for LLM agents creating dynamic cron jobs (e.g. price
alerts). Adding beforeRun to CronCommonOptionalFields makes it
available in both add and patch schemas.

Co-authored-by: Claude Opus 4.6 <[email protected]>
…nchain

feat(observability): emit D0 runtime run traces (PRODUCT-2628)
When the LLM outputs NO_REPLY (a silent control token), the streaming
partial "NO" enters the draft buffer. On finalization, stop() flushed
this text to Telegram before clear() deleted it, causing a visible flash.

Skip stop() when shouldClear=true (no final content delivered) and call
clear() directly, which abandons pending content without flushing.

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
donut-wenzhang and others added 4 commits March 27, 2026 17:49
Block reply messages (sent via deliverReplies) were not tracked after
delivery, so onToolStart could not delete them. This caused intermediate
reasoning text ("Let me load the skill...") to leak as visible TG
messages.

- Add onMessageSent callback to deliverReplies/deliverTextReply to
  track sent message IDs
- In onToolStart, delete all pending block reply messages before
  processing the tool (mirrors existing preview deletion logic)

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
When apiRoot is configured (e.g. for TG proxy mode), file downloads
still hardcoded https://api.telegram.org, causing 404s with virtual
tokens. Now downloadAndSaveTelegramFile respects apiRoot, and SSRF
policy dynamically includes the proxy hostname.

Co-authored-by: Claude Opus 4.6 <[email protected]>
Telegram webhook channels suffered two compounding bugs:

1. The health monitor's stale-socket heuristic falsely triggered on
   webhook-mode channels every 30 minutes because webhook channels
   receive messages via HTTP POST, not socket events.

2. The webhook shutdown() was fire-and-forget — server.close() and
   bot.stop() were not awaited, so a health-monitor restart could
   call startChannel before the old server released its port, causing
   EADDRINUSE or a broken message pipeline (HTTP 200 but zero dispatch).

Changes:
- channel-health-policy: add mode to snapshot, skip stale-socket for
  webhook-mode channels
- webhook.ts: make shutdown await server.close() and bot.stop(),
  expose shutdownComplete() promise
- monitor.ts: await shutdownComplete() after abort in webhook path

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Allow callers to specify role ("assistant" | "user") for injected
messages. Defaults to "assistant" for backward compatibility.

When role=user, assistant-specific fields (stopReason, usage, api,
provider, model) are omitted from the message body.

This enables the backend to inject user-role messages that the bot
treats as instructions to execute (e.g. refreshing tool knowledge
after d0-cli hot-update).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@donut-wenzhang
donut-wenzhang requested a review from a team as a code owner April 4, 2026 05:56
@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation channel: telegram Channel integration: telegram app: web-ui App: web-ui gateway Gateway runtime agents Agent runtime and tooling size: XL labels Apr 4, 2026
@greptile-apps

greptile-apps Bot commented Apr 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds an optional role parameter ("assistant" | "user", defaulting to "assistant") to the chat.inject RPC, threading it through the handler into appendInjectedAssistantMessageToTranscript, which correctly omits assistant-specific metadata fields (stopReason, usage, api, provider, model) for user-role messages. The change is backward compatible and consistent with existing RPC schema patterns.

Confidence Score: 5/5

Safe to merge — focused, backward-compatible addition with correct conditional logic for role-specific metadata.

The only finding is a P2 function naming issue; the logic, schema, and metadata-gating are all correct.

No files require special attention.

Comments Outside Diff (1)

  1. src/gateway/server-methods/chat-transcript-inject.ts, line 18 (link)

    P2 Misleading function name after role expansion

    appendInjectedAssistantMessageToTranscript now handles both assistant and user roles, so "Assistant" in the name is no longer accurate. Consider renaming to something like appendInjectedMessageToTranscript to avoid confusion for future callers who read the name and assume it only writes assistant-role entries.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/gateway/server-methods/chat-transcript-inject.ts
    Line: 18
    
    Comment:
    **Misleading function name after role expansion**
    
    `appendInjectedAssistantMessageToTranscript` now handles both `assistant` and `user` roles, so "Assistant" in the name is no longer accurate. Consider renaming to something like `appendInjectedMessageToTranscript` to avoid confusion for future callers who read the name and assume it only writes assistant-role entries.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/gateway/server-methods/chat-transcript-inject.ts
Line: 18

Comment:
**Misleading function name after role expansion**

`appendInjectedAssistantMessageToTranscript` now handles both `assistant` and `user` roles, so "Assistant" in the name is no longer accurate. Consider renaming to something like `appendInjectedMessageToTranscript` to avoid confusion for future callers who read the name and assume it only writes assistant-role entries.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "feat(gateway): add optional role param t..." | Re-trigger Greptile

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5998c3aa19

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +877 to +878
const isChannelScopedSession = sessionPeerShapeCandidates.some((part) =>
CHANNEL_SCOPED_SESSION_SHAPES.has(part),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle legacy channel keys when inheriting delivery route

This new gate only treats sessions as channel-scoped when one of the next two tokens is direct|dm|group|channel, so legacy keys like agent:main:telegram:12345 (still present in existing stores/tests) will fail canInheritDeliverableRoute even when lastChannel/lastTo are valid. In chat.send, that makes OriginatingChannel fall back to webchat, so follow-up sends from those sessions stop routing back to the original external conversation.

Useful? React with 👍 / 👎.

Comment on lines +319 to 323
if (!toRaw) {
return { tool: toolName, provider, accountId, to: "" };
}
const to = normalizeTargetForProvider(provider, toRaw);
return to ? { tool: toolName, provider, accountId, to } : undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve threadId when tracking message tool deliveries

The message tool path now supports thread-aware suppression, but this return branch drops threadId entirely, so a send like { action: "send", provider: "telegram", to: "-100123", threadId: "77" } is recorded without topic context. Downstream suppression logic then cannot match topic-targeted sends correctly, which causes duplicate final replies in threaded Telegram conversations.

Useful? React with 👍 / 👎.

@donut-wenzhang
donut-wenzhang deleted the feat/chat-inject-role-param branch April 8, 2026 15:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling app: web-ui App: web-ui channel: telegram Channel integration: telegram docs Improvements or additions to documentation gateway Gateway runtime size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants