Skip to content
Merged
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
8 changes: 8 additions & 0 deletions src/agent/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,10 @@ export function normalizeAction(input: unknown): AgentAction {
? String(input.schedule.summaryText ?? "")
: "",
},
timezoneSource:
input.timezoneSource === "explicit" || input.timezoneSource === "default"
? input.timezoneSource
: undefined,
task: String(input.task ?? ""),
channel: toOptionalTrimmedString(input.channel),
to: toOptionalTrimmedString(input.to),
Expand Down Expand Up @@ -348,6 +352,10 @@ export function normalizeAction(input: unknown): AgentAction {
summaryText: String(input.schedule.summaryText ?? ""),
}
: undefined,
timezoneSource:
input.timezoneSource === "explicit" || input.timezoneSource === "default"
? input.timezoneSource
: undefined,
channel: toOptionalTrimmedString(input.channel),
to: toOptionalTrimmedString(input.to),
model: toOptionalTrimmedString(input.model),
Expand Down
82 changes: 71 additions & 11 deletions src/agent/agent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
ChannelMediaDeliveryResult,
ChannelMediaRequest,
CronTaskPlan,
CronTimezoneSource,
HumanAuthDecision,
HumanAuthCapability,
HumanAuthRequest,
Expand Down Expand Up @@ -62,7 +63,7 @@ import {
} from "./journal/task-journal-store.js";
import { runRuntimeAttempt } from "./runtime/attempt.js";
import { runRuntimeTask } from "./runtime/run.js";
import type { RunTaskRequest } from "./runtime/types.js";
import type { ChannelOriginContext, RunTaskRequest } from "./runtime/types.js";
import { AliyunUiAgentClient } from "./aliyun-ui-agent-client.js";
import { AliyunGuiPlusClient } from "./aliyun-gui-plus-client.js";
import { createPiSessionBridge } from "./pi-session-bridge.js";
Expand Down Expand Up @@ -320,6 +321,7 @@ interface PhoneAgentRunContext {
launchablePackages: string[];
taskExecutionPlan: TaskExecutionPlan | null;
cronTaskPlan: CronTaskPlan | null;
channelContext: ChannelOriginContext | null;
runtimeModel: {
id: string;
provider: string;
Expand Down Expand Up @@ -2431,7 +2433,7 @@ export class AgentRuntime {
action.type === "cron_remove" ||
action.type === "cron_update"
) {
executionResult = this.executeCronAction(action);
executionResult = this.executeCronAction(action, ctx);
} else if (action.type === "runtime_info") {
executionResult = this.buildRuntimeInfoActionResult(ctx);
} else {
Expand Down Expand Up @@ -2609,6 +2611,7 @@ export class AgentRuntime {
action: Extract<AgentAction, {
type: "cron_add" | "cron_list" | "cron_remove" | "cron_update";
}>,
ctx?: Pick<PhoneAgentRunContext, "channelContext" | "task">,
): string {
const registry = new CronRegistry(this.config);

Expand All @@ -2624,6 +2627,9 @@ export class AgentRuntime {
}

if (action.type === "cron_update") {
const normalizedSchedule = action.schedule
? this.normalizeCronScheduleTimezone(action.schedule, action.timezoneSource)
: undefined;
const updated = registry.update(action.id, {
name: action.name,
enabled: action.enabled,
Expand All @@ -2633,7 +2639,7 @@ export class AgentRuntime {
task: action.task,
}
: undefined,
schedule: action.schedule,
schedule: normalizedSchedule,
delivery:
action.channel && action.to
? {
Expand All @@ -2651,31 +2657,83 @@ export class AgentRuntime {
: `cron_update missing id=${action.id}`;
}

const inheritedChannel = action.channel ?? ctx?.channelContext?.channelType ?? null;
const inheritedPeerId = action.to ?? ctx?.channelContext?.peerId ?? null;
const inheritedSourceChannel = action.sourceChannel ?? ctx?.channelContext?.channelType ?? null;
const inheritedSourcePeerId = action.sourcePeerId ?? ctx?.channelContext?.peerId ?? null;
const inheritedCreatedBy = action.createdBy
?? (ctx?.channelContext
? `${ctx.channelContext.channelType}:${ctx.channelContext.senderId ?? ctx.channelContext.peerId}`
: undefined);
const normalizedSchedule = this.normalizeCronScheduleTimezone(action.schedule, action.timezoneSource);

const created = registry.add({
id: action.id,
name: action.name,
enabled: true,
schedule: action.schedule,
schedule: normalizedSchedule,
payload: {
kind: "agent_turn",
task: action.task,
},
delivery:
action.channel && action.to
inheritedChannel && inheritedPeerId
? {
mode: "announce",
channel: action.channel,
to: action.to,
channel: inheritedChannel,
to: inheritedPeerId,
}
: null,
model: action.model ?? null,
promptMode: action.promptMode ?? "minimal",
runOnStartup: action.runOnStartup,
createdBy: action.createdBy,
sourceChannel: action.sourceChannel,
sourcePeerId: action.sourcePeerId,
createdBy: inheritedCreatedBy,
sourceChannel: inheritedSourceChannel,
sourcePeerId: inheritedSourcePeerId,
});
return `cron_add ok id=${created.id}`;
return `cron_add ok id=${created.id} tz=${created.schedule.tz}`;
}

private normalizeCronScheduleTimezone<
T extends {
kind: "cron" | "at" | "every";
tz: string;
},
>(schedule: T, timezoneSource?: CronTimezoneSource): T {
if (timezoneSource === "explicit") {
return schedule;
}
const preferredTimezone = this.resolvePreferredScheduleTimezone();
if (!preferredTimezone || schedule.tz === preferredTimezone) {
return schedule;
}
return {
...schedule,
tz: preferredTimezone,
};
}

private resolvePreferredScheduleTimezone(): string {
const userPath = path.join(this.config.workspaceDir, "USER.md");
try {
const user = fs.readFileSync(userPath, "utf-8");
const timezoneLine = user
.split(/\r?\n/)
.find((line) => /^\s*-\s*Timezone\s*:/i.test(line));
const configured = timezoneLine
? timezoneLine.replace(/^\s*-\s*Timezone\s*:\s*/i, "").trim()
: "";
if (configured) {
try {
return new Intl.DateTimeFormat("en-US", { timeZone: configured }).resolvedOptions().timeZone;
} catch {
// Fall through to the process timezone below.
}
}
} catch {
// Fall back to the process timezone below.
}
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
}

private executeJournalAction(
Expand Down Expand Up @@ -3781,6 +3839,7 @@ export class AgentRuntime {
availableToolNamesOverride?: string[],
maxStepsOverride?: number,
cronTaskPlan?: CronTaskPlan | null,
channelContext?: ChannelOriginContext | null,
): Promise<AgentRunResult> {
const activeToolNames = Array.isArray(availableToolNamesOverride) && availableToolNamesOverride.length > 0
? availableToolNamesOverride
Expand All @@ -3799,6 +3858,7 @@ export class AgentRuntime {
taskExecutionPlan,
maxStepsOverride,
cronTaskPlan,
channelContext,
};

return runRuntimeTask(
Expand Down
6 changes: 4 additions & 2 deletions src/agent/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ const TOOL_CATALOG_LINES: Record<(typeof TOOL_CATALOG_ORDER)[number], string> =
shell: "- shell: shell(command[, reason])",
batch_actions: "- batch_actions: batch_actions(actions[, reason])",
run_script: "- run_script: run_script(script[, timeoutSec, reason])",
cron_add: "- cron_add: cron_add(id, name, schedule, task[, channel, to, model, promptMode, runOnStartup, createdBy, sourceChannel, sourcePeerId, reason])",
cron_add: "- cron_add: cron_add(id, name, schedule, task[, timezoneSource, channel, to, model, promptMode, runOnStartup, createdBy, sourceChannel, sourcePeerId, reason])",
cron_list: "- cron_list: cron_list([reason])",
cron_remove: "- cron_remove: cron_remove(id[, reason])",
cron_update: "- cron_update: cron_update(id[, name, enabled, task, schedule, channel, to, model, promptMode, runOnStartup, reason])",
cron_update: "- cron_update: cron_update(id[, name, enabled, task, schedule, timezoneSource, channel, to, model, promptMode, runOnStartup, reason])",
runtime_info: "- runtime_info: runtime_info([reason])",
read: "- read: read(path[, from, lines, reason])",
write: "- write: write(path, content[, append, reason])",
Expand Down Expand Up @@ -229,6 +229,7 @@ export function buildSystemPrompt(
"- Use request_user_input for non-sensitive short text values needed to proceed (for example vehicle plate/label).",
"- Never use request_user_decision to collect credentials/OTP/payment or personal identity data.",
"- Never use request_user_input to collect credentials/OTP/payment or personal identity data.",
"- For cron_add/cron_update, never infer a timezone from the user's language. Set timezoneSource=explicit only when the user explicitly named a timezone. Set timezoneSource=default when using the workspace/user default timezone. If you are not confident the default timezone is intended, ask with request_user_input before calling cron_add/cron_update.",
"- If done, call finish with key outputs.",
"",
...buildSkillsSection({
Expand Down Expand Up @@ -298,6 +299,7 @@ export function buildSystemPrompt(
"- For memory questions about prior decisions/preferences/history, use memory_search first, then memory_get for exact lines.",
"- Keep file operations inside workspace unless explicit override is provided by workspace policy.",
"- Keep actions practical and reproducible.",
"- For cron_add/cron_update, never infer a timezone from the user's language. Set timezoneSource=explicit only when the user explicitly named a timezone. Set timezoneSource=default when using the workspace/user default timezone. If you are not confident the default timezone is intended, ask with request_user_input before calling cron_add/cron_update.",
"",
"## Device Ownership Model",
"- Agent Phone: the Android device you control. It is a CLEAN/SHARED device with NO user personal data.",
Expand Down
1 change: 1 addition & 0 deletions src/agent/runtime/attempt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,7 @@ export async function runRuntimeAttempt(
launchablePackages,
taskExecutionPlan: request.taskExecutionPlan ?? null,
cronTaskPlan: request.cronTaskPlan ?? null,
channelContext: request.channelContext ?? null,
runtimeModel: profile.backend === "aliyun_ui_agent_mobile" || profile.backend === "aliyun_gui_plus"
? {
id: effectiveProfile.model,
Expand Down
8 changes: 8 additions & 0 deletions src/agent/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ import { type AutoArtifactBuilder, type StepTrace } from "../../skills/auto-arti
import type { SkillLoader } from "../../skills/skill-loader.js";
import type { SystemPromptMode } from "../prompts.js";

export interface ChannelOriginContext {
channelType: string;
peerId: string;
senderId?: string | null;
}

export interface RunTaskRequest {
task: string;
modelName?: string;
Expand All @@ -42,6 +48,7 @@ export interface RunTaskRequest {
taskExecutionPlan?: TaskExecutionPlan | null;
maxStepsOverride?: number;
cronTaskPlan?: CronTaskPlan | null;
channelContext?: ChannelOriginContext | null;
}

export interface RunTaskAttemptOutcome {
Expand Down Expand Up @@ -208,6 +215,7 @@ export interface PhoneAgentRunContext {
launchablePackages: string[];
taskExecutionPlan: TaskExecutionPlan | null;
cronTaskPlan: CronTaskPlan | null;
channelContext: ChannelOriginContext | null;
runtimeModel: RuntimeModelMetadata;
effectivePromptMode: SystemPromptMode;
systemPrompt: string;
Expand Down
10 changes: 10 additions & 0 deletions src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,11 +175,20 @@ const cronScheduleSchema = Type.Object({
summaryText: Type.String({ description: "Human-readable schedule summary." }),
});

const cronTimezoneSourceSchema = Type.Union([
Type.Literal("explicit"),
Type.Literal("default"),
], {
description:
"Whether the timezone came explicitly from the user or was defaulted from workspace/user profile settings.",
});

export const cronAddSchema = Type.Object({
thought: ThoughtParam,
id: Type.String({ description: "Unique cron job id." }),
name: Type.String({ description: "Display name for the cron job." }),
schedule: cronScheduleSchema,
timezoneSource: Type.Optional(cronTimezoneSourceSchema),
task: Type.String({ description: "Natural-language task to run when the job fires." }),
channel: Type.Optional(Type.String({ description: "Optional delivery channel." })),
to: Type.Optional(Type.String({ description: "Optional delivery target/peer id." })),
Expand Down Expand Up @@ -210,6 +219,7 @@ export const cronUpdateSchema = Type.Object({
enabled: Type.Optional(Type.Boolean({ description: "Enable or disable the job." })),
task: Type.Optional(Type.String({ description: "Updated task text." })),
schedule: Type.Optional(cronScheduleSchema),
timezoneSource: Type.Optional(cronTimezoneSourceSchema),
channel: Type.Optional(Type.String({ description: "Updated delivery channel." })),
to: Type.Optional(Type.String({ description: "Updated delivery target." })),
model: Type.Optional(Type.String({ description: "Updated model override." })),
Expand Down
6 changes: 5 additions & 1 deletion src/gateway/chat-assistant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2441,11 +2441,12 @@ export class ChatAssistant {
inputText: string,
): Promise<ScheduleIntentExtractionDecision | null> {
const locale: OnboardingLocale = inferScheduleIntentLocale(inputText);
const defaultTimezone = this.scheduleTimezoneForInput();
const recentContext = this.buildRoutingContextTranscript(chatId);
const prompt = [
"Determine whether the user message asks to create a scheduled job, manage an existing scheduled job, or neither.",
"Output strict JSON only:",
'{"route":"create_schedule|manage_schedule|none","task":"<task or empty>","manageIntent":{"action":"list|update|remove|enable|disable|unknown","selector":{"all":true|false,"ids":["<job id>"],"nameContains":["<name fragment>"],"taskContains":["<task fragment>"],"scheduleContains":["<schedule fragment>"],"enabled":"any|enabled|disabled"},"patch":{"name":"<new name or empty>","task":"<new task or empty>","enabled":true|false|null,"schedule":{"kind":"cron|at|every","expr":"<cron expr or empty>","at":"<RFC3339 datetime or empty>","everyMs":number|null,"tz":"<IANA timezone or empty>","summaryText":"<short schedule summary>"}}},"schedule":{"kind":"cron|at|every","expr":"<cron expr or empty>","at":"<RFC3339 datetime or empty>","everyMs":number|null,"tz":"<IANA timezone or empty>","summaryText":"<concise schedule summary in the user language>"},"confidence":0-1,"reason":"..."}',
'{"route":"create_schedule|manage_schedule|none","task":"<task or empty>","manageIntent":{"action":"list|update|remove|enable|disable|unknown","selector":{"all":true|false,"ids":["<job id>"],"nameContains":["<name fragment>"],"taskContains":["<task fragment>"],"scheduleContains":["<schedule fragment>"],"enabled":"any|enabled|disabled"},"patch":{"name":"<new name or empty>","task":"<new task or empty>","enabled":true|false|null,"schedule":{"kind":"cron|at|every","expr":"<cron expr or empty>","at":"<RFC3339 datetime or empty>","everyMs":number|null,"tz":"<IANA timezone or empty>","timezoneSource":"explicit|default","summaryText":"<short schedule summary>"}}},"schedule":{"kind":"cron|at|every","expr":"<cron expr or empty>","at":"<RFC3339 datetime or empty>","everyMs":number|null,"tz":"<IANA timezone or empty>","timezoneSource":"explicit|default","summaryText":"<concise schedule summary in the user language>"},"confidence":0-1,"reason":"..."}',
"Rules:",
"1) Return route=create_schedule for explicit or implicit requests to create a new recurring or one-shot scheduled task/reminder.",
"2) Return route=manage_schedule for requests to inspect, list, modify, rename, enable, disable, delete, or otherwise manage an existing cron job or scheduled task.",
Expand All @@ -2464,7 +2465,10 @@ export class ChatAssistant {
"13) Use kind=at only for one-shot future schedules when you can provide an RFC3339 datetime.",
"14) summaryText must be short and in the user's language when route=create_schedule.",
"15) If the schedule is ambiguous or any required field is missing for route=create_schedule, return route=none instead of guessing.",
'16) If the user explicitly specifies a timezone, set schedule.timezoneSource="explicit" and set schedule.tz to that timezone.',
'17) If the user does not explicitly specify a timezone, leave schedule.tz empty or use the default timezone below, and set schedule.timezoneSource="default". Never infer timezone from the user\'s language or script.',
`User locale hint: ${locale}`,
`Default timezone: ${defaultTimezone}`,
this.buildExistingJobsCatalog(),
recentContext ? `Recent conversation context:\n${recentContext}` : "",
`User message: ${inputText}`,
Expand Down
Loading
Loading