2026-01-04 05:07:37 +01:00
import { Type } from "@sinclair/typebox" ;
2026-01-14 14:31:43 +00:00
import { normalizeCronJobCreate , normalizeCronJobPatch } from "../../cron/normalize.js" ;
2026-01-16 13:58:29 +09:00
import { loadConfig } from "../../config/config.js" ;
import { truncateUtf16Safe } from "../../utils.js" ;
2026-01-13 06:28:09 +00:00
import { optionalStringEnum , stringEnum } from "../schema/typebox.js" ;
2026-01-06 03:25:21 +01:00
import { type AnyAgentTool , jsonResult , readStringParam } from "./common.js" ;
import { callGatewayTool , type GatewayCallOptions } from "./gateway.js" ;
2026-01-16 13:58:29 +09:00
import { resolveInternalSessionKey , resolveMainSessionAlias } from "./sessions-helpers.js" ;
2026-01-05 23:09:48 -03:00
2026-01-06 13:43:09 +05:30
// NOTE: We use Type.Object({}, { additionalProperties: true }) for job/patch
2026-01-13 06:28:09 +00:00
// instead of CronAddParamsSchema/CronJobPatchSchema because the gateway schemas
// contain nested unions. Tool schemas need to stay provider-friendly, so we
// accept "any object" here and validate at runtime.
2026-01-04 05:07:37 +01:00
2026-01-14 14:31:43 +00:00
const CRON_ACTIONS = [ "status" , "list" , "add" , "update" , "remove" , "run" , "runs" , "wake" ] as const ;
2026-01-13 06:28:09 +00:00
const CRON_WAKE_MODES = [ "now" , "next-heartbeat" ] as const ;
2026-01-16 13:58:29 +09:00
const REMINDER_CONTEXT_MESSAGES = 3 ;
const REMINDER_CONTEXT_PER_MESSAGE_MAX = 220 ;
const REMINDER_CONTEXT_TOTAL_MAX = 700 ;
const REMINDER_CONTEXT_MARKER = "\n\nRecent context:\n" ;
2026-01-13 06:28:09 +00:00
// Flattened schema: runtime validates per-action requirements.
const CronToolSchema = Type . Object ( {
action : stringEnum ( CRON_ACTIONS ) ,
gatewayUrl : Type.Optional ( Type . String ( ) ) ,
gatewayToken : Type.Optional ( Type . String ( ) ) ,
timeoutMs : Type.Optional ( Type . Number ( ) ) ,
includeDisabled : Type.Optional ( Type . Boolean ( ) ) ,
job : Type.Optional ( Type . Object ( { } , { additionalProperties : true } ) ) ,
jobId : Type.Optional ( Type . String ( ) ) ,
id : Type.Optional ( Type . String ( ) ) ,
patch : Type.Optional ( Type . Object ( { } , { additionalProperties : true } ) ) ,
text : Type.Optional ( Type . String ( ) ) ,
mode : optionalStringEnum ( CRON_WAKE_MODES ) ,
} ) ;
2026-01-04 05:07:37 +01:00
2026-01-16 13:58:29 +09:00
type CronToolOptions = {
agentSessionKey? : string ;
} ;
type ChatMessage = {
role? : unknown ;
content? : unknown ;
} ;
function stripExistingContext ( text : string ) {
const index = text . indexOf ( REMINDER_CONTEXT_MARKER ) ;
if ( index === - 1 ) return text ;
return text . slice ( 0 , index ) . trim ( ) ;
}
function truncateText ( input : string , maxLen : number ) {
if ( input . length <= maxLen ) return input ;
const truncated = truncateUtf16Safe ( input , Math . max ( 0 , maxLen - 3 ) ) . trimEnd ( ) ;
return ` ${ truncated } ... ` ;
}
function normalizeContextText ( raw : string ) {
return raw . replace ( /\s+/g , " " ) . trim ( ) ;
}
function extractMessageText ( message : ChatMessage ) : { role : string ; text : string } | null {
const role = typeof message . role === "string" ? message . role : "" ;
if ( role !== "user" && role !== "assistant" ) return null ;
const content = message . content ;
if ( typeof content === "string" ) {
const normalized = normalizeContextText ( content ) ;
return normalized ? { role , text : normalized } : null ;
}
if ( ! Array . isArray ( content ) ) return null ;
const chunks : string [ ] = [ ] ;
for ( const block of content ) {
if ( ! block || typeof block !== "object" ) continue ;
if ( ( block as { type ? : unknown } ) . type !== "text" ) continue ;
const text = ( block as { text? : unknown } ) . text ;
if ( typeof text === "string" && text . trim ( ) ) {
chunks . push ( text ) ;
}
}
const joined = normalizeContextText ( chunks . join ( " " ) ) ;
return joined ? { role , text : joined } : null ;
}
async function buildReminderContextLines ( params : {
agentSessionKey? : string ;
gatewayOpts : GatewayCallOptions ;
} ) {
const sessionKey = params . agentSessionKey ? . trim ( ) ;
if ( ! sessionKey ) return [ ] ;
const cfg = loadConfig ( ) ;
const { mainKey , alias } = resolveMainSessionAlias ( cfg ) ;
const resolvedKey = resolveInternalSessionKey ( { key : sessionKey , alias , mainKey } ) ;
try {
const res = ( await callGatewayTool ( "chat.history" , params . gatewayOpts , {
sessionKey : resolvedKey ,
limit : 12 ,
} ) ) as { messages? : unknown [ ] } ;
const messages = Array . isArray ( res ? . messages ) ? res . messages : [ ] ;
const parsed = messages
. map ( ( msg ) = > extractMessageText ( msg as ChatMessage ) )
. filter ( ( msg ) : msg is { role : string ; text : string } = > Boolean ( msg ) ) ;
const recent = parsed . slice ( - REMINDER_CONTEXT_MESSAGES ) ;
if ( recent . length === 0 ) return [ ] ;
const lines : string [ ] = [ ] ;
let total = 0 ;
for ( const entry of recent ) {
const label = entry . role === "user" ? "User" : "Assistant" ;
const text = truncateText ( entry . text , REMINDER_CONTEXT_PER_MESSAGE_MAX ) ;
const line = ` - ${ label } : ${ text } ` ;
total += line . length ;
if ( total > REMINDER_CONTEXT_TOTAL_MAX ) break ;
lines . push ( line ) ;
}
return lines ;
} catch {
return [ ] ;
}
}
export function createCronTool ( opts? : CronToolOptions ) : AnyAgentTool {
2026-01-04 05:07:37 +01:00
return {
label : "Cron" ,
name : "cron" ,
description :
2026-01-08 20:46:58 +01:00
"Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use `jobId` as the canonical identifier; `id` is accepted for compatibility." ,
2026-01-04 05:07:37 +01:00
parameters : CronToolSchema ,
execute : async ( _toolCallId , args ) = > {
const params = args as Record < string , unknown > ;
const action = readStringParam ( params , "action" , { required : true } ) ;
const gatewayOpts : GatewayCallOptions = {
gatewayUrl : readStringParam ( params , "gatewayUrl" , { trim : false } ) ,
gatewayToken : readStringParam ( params , "gatewayToken" , { trim : false } ) ,
2026-01-14 14:31:43 +00:00
timeoutMs : typeof params . timeoutMs === "number" ? params.timeoutMs : undefined ,
2026-01-04 05:07:37 +01:00
} ;
switch ( action ) {
case "status" :
2026-01-14 14:31:43 +00:00
return jsonResult ( await callGatewayTool ( "cron.status" , gatewayOpts , { } ) ) ;
2026-01-04 05:07:37 +01:00
case "list" :
return jsonResult (
await callGatewayTool ( "cron.list" , gatewayOpts , {
includeDisabled : Boolean ( params . includeDisabled ) ,
} ) ,
) ;
case "add" : {
if ( ! params . job || typeof params . job !== "object" ) {
throw new Error ( "job required" ) ;
}
2026-01-05 23:09:48 -03:00
const job = normalizeCronJobCreate ( params . job ) ? ? params . job ;
2026-01-16 13:58:29 +09:00
if (
job &&
typeof job === "object" &&
"payload" in job &&
( job as { payload ? : { kind? : string ; text? : string } } ) . payload ? . kind === "systemEvent"
) {
const payload = ( job as { payload : { kind : string ; text : string } } ) . payload ;
if ( typeof payload . text === "string" && payload . text . trim ( ) ) {
const contextLines = await buildReminderContextLines ( {
agentSessionKey : opts?.agentSessionKey ,
gatewayOpts ,
} ) ;
if ( contextLines . length > 0 ) {
const baseText = stripExistingContext ( payload . text ) ;
payload . text = ` ${ baseText } ${ REMINDER_CONTEXT_MARKER } ${ contextLines . join ( "\n" ) } ` ;
}
}
}
2026-01-14 14:31:43 +00:00
return jsonResult ( await callGatewayTool ( "cron.add" , gatewayOpts , job ) ) ;
2026-01-04 05:07:37 +01:00
}
case "update" : {
2026-01-14 14:31:43 +00:00
const id = readStringParam ( params , "jobId" ) ? ? readStringParam ( params , "id" ) ;
2026-01-08 20:46:58 +01:00
if ( ! id ) {
2026-01-14 14:31:43 +00:00
throw new Error ( "jobId required (id accepted for backward compatibility)" ) ;
2026-01-08 20:46:58 +01:00
}
2026-01-04 05:07:37 +01:00
if ( ! params . patch || typeof params . patch !== "object" ) {
throw new Error ( "patch required" ) ;
}
2026-01-05 23:09:48 -03:00
const patch = normalizeCronJobPatch ( params . patch ) ? ? params . patch ;
2026-01-04 05:07:37 +01:00
return jsonResult (
await callGatewayTool ( "cron.update" , gatewayOpts , {
2026-01-04 14:57:26 +00:00
id ,
2026-01-05 23:09:48 -03:00
patch ,
2026-01-04 05:07:37 +01:00
} ) ,
) ;
}
case "remove" : {
2026-01-14 14:31:43 +00:00
const id = readStringParam ( params , "jobId" ) ? ? readStringParam ( params , "id" ) ;
2026-01-08 20:46:58 +01:00
if ( ! id ) {
2026-01-14 14:31:43 +00:00
throw new Error ( "jobId required (id accepted for backward compatibility)" ) ;
2026-01-08 20:46:58 +01:00
}
2026-01-14 14:31:43 +00:00
return jsonResult ( await callGatewayTool ( "cron.remove" , gatewayOpts , { id } ) ) ;
2026-01-04 05:07:37 +01:00
}
case "run" : {
2026-01-14 14:31:43 +00:00
const id = readStringParam ( params , "jobId" ) ? ? readStringParam ( params , "id" ) ;
2026-01-08 20:46:58 +01:00
if ( ! id ) {
2026-01-14 14:31:43 +00:00
throw new Error ( "jobId required (id accepted for backward compatibility)" ) ;
2026-01-08 20:46:58 +01:00
}
2026-01-14 14:31:43 +00:00
return jsonResult ( await callGatewayTool ( "cron.run" , gatewayOpts , { id } ) ) ;
2026-01-04 05:07:37 +01:00
}
case "runs" : {
2026-01-14 14:31:43 +00:00
const id = readStringParam ( params , "jobId" ) ? ? readStringParam ( params , "id" ) ;
2026-01-08 20:46:58 +01:00
if ( ! id ) {
2026-01-14 14:31:43 +00:00
throw new Error ( "jobId required (id accepted for backward compatibility)" ) ;
2026-01-08 20:46:58 +01:00
}
2026-01-14 14:31:43 +00:00
return jsonResult ( await callGatewayTool ( "cron.runs" , gatewayOpts , { id } ) ) ;
2026-01-04 05:07:37 +01:00
}
case "wake" : {
const text = readStringParam ( params , "text" , { required : true } ) ;
const mode =
params . mode === "now" || params . mode === "next-heartbeat"
? params . mode
: "next-heartbeat" ;
return jsonResult (
2026-01-14 14:31:43 +00:00
await callGatewayTool ( "wake" , gatewayOpts , { mode , text } , { expectFinal : false } ) ,
2026-01-04 05:07:37 +01:00
) ;
}
default :
throw new Error ( ` Unknown action: ${ action } ` ) ;
}
} ,
} ;
}