Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ FROM alpine:3.22

ARG ENABLE_SANDBOX=false
ARG ENABLE_PYTHON=false
ARG ENABLE_NODE=false

# Install ca-certificates + wget (healthcheck) + optionally docker-cli (sandbox) + python3
# Install ca-certificates + wget (healthcheck) + optionally docker-cli (sandbox) + python3 + nodejs
RUN set -eux; \
apk add --no-cache ca-certificates wget; \
if [ "$ENABLE_SANDBOX" = "true" ]; then \
Expand All @@ -48,6 +49,9 @@ RUN set -eux; \
if [ "$ENABLE_PYTHON" = "true" ]; then \
apk add --no-cache python3 py3-pip; \
pip3 install --break-system-packages pypdf; \
fi; \
if [ "$ENABLE_NODE" = "true" ]; then \
apk add --no-cache nodejs npm; \
fi

# Non-root user
Expand Down
40 changes: 19 additions & 21 deletions cmd/gateway_cron.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,25 @@ import (
// and integration with /stop, /stopall commands.
func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg *config.Config, channelMgr *channels.Manager) func(job *store.CronJob) (*store.CronJobResult, error) {
return func(job *store.CronJob) (*store.CronJobResult, error) {
peerKind := resolveCronPeerKind(job)

// Direct delivery: send Payload.Message straight to the channel without agent processing.
// This is the correct behavior for reminders/notifications — bot sends one message directly.
if job.Payload.Deliver && job.Payload.Channel != "" && job.Payload.To != "" {
outMsg := bus.OutboundMessage{
Channel: job.Payload.Channel,
ChatID: job.Payload.To,
Content: job.Payload.Message,
}
if peerKind == "group" {
outMsg.Metadata = map[string]string{"group_id": job.Payload.To}
}
msgBus.PublishOutbound(outMsg)

return &store.CronJobResult{Content: job.Payload.Message}, nil
}

// Agent processing: route through scheduler for LLM-powered cron tasks.
agentID := job.AgentID
if agentID == "" {
agentID = cfg.ResolveDefaultAgentID()
Expand All @@ -32,11 +51,6 @@ func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg
channel = "cron"
}

// Infer peer kind from the stored session metadata (group chats need it
// so that tools like message can route correctly via group APIs).
peerKind := resolveCronPeerKind(job)

// Resolve channel type for system prompt context.
channelType := resolveChannelType(channelMgr, channel)

// Build cron context so the agent knows delivery target and requester.
Expand Down Expand Up @@ -72,28 +86,12 @@ func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg
TraceTags: []string{"cron"},
})

// Block until the scheduled run completes
outcome := <-outCh
if outcome.Err != nil {
return nil, outcome.Err
}

result := outcome.Result

// If job wants delivery to a channel, send the agent response to the target chat.
if job.Payload.Deliver && job.Payload.Channel != "" && job.Payload.To != "" {
outMsg := bus.OutboundMessage{
Channel: job.Payload.Channel,
ChatID: job.Payload.To,
Content: result.Content,
}
if peerKind == "group" {
outMsg.Metadata = map[string]string{"group_id": job.Payload.To}
}
appendMediaToOutbound(&outMsg, result.Media)
msgBus.PublishOutbound(outMsg)
}

cronResult := &store.CronJobResult{
Content: result.Content,
}
Expand Down
20 changes: 12 additions & 8 deletions internal/tools/cron.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,16 +230,20 @@ func (t *CronTool) handleAdd(ctx context.Context, args map[string]any, agentID,
channel, _ := jobObj["channel"].(string)
to, _ := jobObj["to"].(string)

// Auto-fill channel and to from context when deliver is requested.
// Auto-fill channel and to from context.
// Always prefer context values over LLM-provided values to prevent
// misrouted deliveries (e.g. LLM confusing guild ID with channel ID).
if deliver {
if ctxChannel := ToolChannelFromCtx(ctx); ctxChannel != "" {
channel = ctxChannel
}
if ctxChatID := ToolChatIDFromCtx(ctx); ctxChatID != "" {
to = ctxChatID
}
if ctxChannel := ToolChannelFromCtx(ctx); ctxChannel != "" {
channel = ctxChannel
}
if ctxChatID := ToolChatIDFromCtx(ctx); ctxChatID != "" {
to = ctxChatID
}

// Default deliver=true when created from a chat context (channel + to available).
// This ensures reminders are sent directly as bot messages, not routed through the agent.
if !deliver && channel != "" && to != "" {
deliver = true
}

// Use agent ID from job object if explicitly provided, otherwise from context
Expand Down
3 changes: 3 additions & 0 deletions ui/web/src/i18n/locales/en/setup.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,5 +98,8 @@
"requiredFields": "Required: {{fields}}",
"failedCreate": "Failed to create channel"
}
},
"common": {
"back": "Back"
}
}
3 changes: 3 additions & 0 deletions ui/web/src/i18n/locales/vi/setup.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,5 +98,8 @@
"requiredFields": "Bắt buộc: {{fields}}",
"failedCreate": "Không thể tạo channel"
}
},
"common": {
"back": "Quay lại"
}
}
3 changes: 3 additions & 0 deletions ui/web/src/i18n/locales/zh/setup.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,5 +98,8 @@
"requiredFields": "必填项:{{fields}}",
"failedCreate": "创建Channel失败"
}
},
"common": {
"back": "返回"
}
}
3 changes: 3 additions & 0 deletions ui/web/src/pages/setup/setup-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export function SetupPage() {
{step === 2 && activeProvider && (
<StepModel
provider={activeProvider}
onBack={() => setStep(1)}
onComplete={(model) => {
setSelectedModel(model);
setStep(3);
Expand All @@ -85,6 +86,7 @@ export function SetupPage() {
<StepAgent
provider={activeProvider}
model={selectedModel}
onBack={() => setStep(2)}
onComplete={(agent) => {
setCreatedAgent(agent);
setStep(4);
Expand All @@ -95,6 +97,7 @@ export function SetupPage() {
{step === 4 && (
<StepChannel
agent={activeAgent}
onBack={() => setStep(3)}
onComplete={handleFinish}
onSkip={handleFinish}
/>
Expand Down
10 changes: 8 additions & 2 deletions ui/web/src/pages/setup/step-agent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ const DEFAULT_PROMPT = `You are GoClaw, my helpful assistant. I am your boss, Ne
interface StepAgentProps {
provider: ProviderData | null;
model: string | null;
onBack?: () => void;
onComplete: (agent: AgentData) => void;
}

export function StepAgent({ provider, model, onComplete }: StepAgentProps) {
export function StepAgent({ provider, model, onBack, onComplete }: StepAgentProps) {
const { t } = useTranslation("setup");
const { createAgent, deleteAgent, resummonAgent } = useAgents();

Expand Down Expand Up @@ -214,7 +215,12 @@ export function StepAgent({ provider, model, onComplete }: StepAgentProps) {

{error && <p className="text-sm text-destructive">{error}</p>}

<div className="flex justify-end">
<div className={`flex ${onBack ? "justify-between" : "justify-end"} gap-2`}>
{onBack && (
<Button variant="secondary" onClick={onBack}>
← {t("common.back")}
</Button>
)}
<Button
onClick={handleCreate}
disabled={loading || !agentKey.trim() || !isValidSlug(agentKey) || !description.trim()}
Expand Down
24 changes: 16 additions & 8 deletions ui/web/src/pages/setup/step-channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ import type { AgentData } from "@/types/agent";

interface StepChannelProps {
agent: AgentData | null;
onBack?: () => void;
onComplete: () => void;
onSkip: () => void;
}

export function StepChannel({ agent, onComplete, onSkip }: StepChannelProps) {
export function StepChannel({ agent, onBack, onComplete, onSkip }: StepChannelProps) {
const { t } = useTranslation("setup");
const { createInstance } = useChannelInstances();

Expand Down Expand Up @@ -158,13 +159,20 @@ export function StepChannel({ agent, onComplete, onSkip }: StepChannelProps) {

{error && <p className="text-sm text-destructive">{error}</p>}

<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onSkip} disabled={loading}>
{t("channel.skipFinish")}
</Button>
<Button onClick={handleCreate} disabled={loading}>
{loading ? t("channel.creating") : t("channel.create")}
</Button>
<div className={`flex ${onBack ? "justify-between" : "justify-end"} gap-2`}>
{onBack && (
<Button variant="secondary" onClick={onBack}>
← {t("common.back")}
</Button>
)}
<div className="flex gap-2">
<Button variant="outline" onClick={onSkip} disabled={loading}>
{t("channel.skipFinish")}
</Button>
<Button onClick={handleCreate} disabled={loading}>
{loading ? t("channel.creating") : t("channel.create")}
</Button>
</div>
</div>
</TooltipProvider>
</CardContent>
Expand Down
32 changes: 20 additions & 12 deletions ui/web/src/pages/setup/step-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ import type { ProviderData } from "@/types/provider";

interface StepModelProps {
provider: ProviderData;
onBack?: () => void;
onComplete: (model: string) => void;
}

export function StepModel({ provider, onComplete }: StepModelProps) {
export function StepModel({ provider, onBack, onComplete }: StepModelProps) {
const { t } = useTranslation("setup");
const { models, loading: modelsLoading } = useProviderModels(provider.id, provider.provider_type);
const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify();
Expand Down Expand Up @@ -90,17 +91,24 @@ export function StepModel({ provider, onComplete }: StepModelProps) {
</div>
)}

<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={handleVerify}
disabled={!model.trim() || verifying || isVerified}
>
{verifying ? t("model.verifying") : isVerified ? t("model.verified") : t("model.verify")}
</Button>
<Button onClick={() => onComplete(model.trim())} disabled={!isVerified}>
{t("model.continue")}
</Button>
<div className={`flex ${onBack ? "justify-between" : "justify-end"} gap-2`}>
{onBack && (
<Button variant="secondary" onClick={onBack}>
{t("common.back")}
</Button>
)}
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleVerify}
disabled={!model.trim() || verifying || isVerified}
>
{verifying ? t("model.verifying") : isVerified ? t("model.verified") : t("model.verify")}
</Button>
<Button onClick={() => onComplete(model.trim())} disabled={!isVerified}>
{t("model.continue")}
</Button>
</div>
</div>
</TooltipProvider>
</CardContent>
Expand Down
Loading