Skip to content

feat: proactive plugin#25

Draft
kwaa wants to merge 2 commits into
mainfrom
feat/proactive
Draft

feat: proactive plugin#25
kwaa wants to merge 2 commits into
mainfrom
feat/proactive

Conversation

@kwaa
Copy link
Copy Markdown
Member

@kwaa kwaa commented May 27, 2026

No description provided.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a new @apeira/plugin-proactive package, which enables proactive agent behavior using a Default Mode Network (DMN) state machine, a task scheduler, and a todo list. It also updates the core package to support sending unsolicited messages via a send callback in session options. The review feedback highlights critical issues in the tick scheduling logic, specifically the premature pruning of one-time tasks before they are sent to the agent, and the lack of await on the asynchronous state.send call, which can cause race conditions and unhandled rejections. Additionally, the sleep tool's duration parameter is currently ignored in favor of a hardcoded resting interval, which should be resolved.

Comment on lines +131 to +160
state.timer = setTimeout(() => {
state.scheduler.prune()

if (state.dmn.state === 'paused')
return scheduleTick(state)

if (shouldSkipTick(state.dmn))
return scheduleTick(state)

state.dmn.lastTickAt = Date.now()

const content = buildTickContent(state)

// Acknowledge interval tasks so they don't repeat on every tick
for (const task of state.scheduler.due()) {
if (task.type === 'interval')
task.lastTriggeredAt = Date.now()
}

try {
state.send?.({ content, role: 'user', type: 'message' })
}
catch {
if (state.timer != null)
clearTimeout(state.timer)
return
}

scheduleTick(state)
}, interval)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

This block contains two critical issues:

  1. Premature Pruning of once Tasks: Calling state.scheduler.prune() at the start of the tick deletes all due once tasks before buildTickContent is called. As a result, state.scheduler.due() returns an empty list for these tasks, and they are never included in the tick message sent to the agent.
  2. Unawaited Asynchronous state.send: state.send (which wraps runtime.send) is asynchronous. Calling it without await means the next tick is scheduled immediately, leading to concurrent ticks/turns being sent to the LLM (especially in the 3-second working state), which causes race conditions and API errors. It also prevents the try/catch block from catching asynchronous rejections, causing unhandled promise rejections and failing to clean up the timer/session.

Additionally, we should persist the updated scheduler and DMN state to storage immediately after updating them to prevent duplicate task execution on server restarts.

state.timer = setTimeout(async () => {
      if (state.dmn.state === 'paused')
        return scheduleTick(state)

      if (shouldSkipTick(state.dmn))
        return scheduleTick(state)

      state.dmn.lastTickAt = Date.now()

      const content = buildTickContent(state)

      // Acknowledge interval tasks and remove completed once tasks
      for (const task of state.scheduler.due()) {
        if (task.type === 'interval') {
          task.lastTriggeredAt = Date.now()
        } else if (task.type === 'once') {
          state.scheduler.remove(task.id)
        }
      }

      // Persist the updated scheduler and DMN state
      await saveState(state)

      try {
        await state.send?.({ content, role: 'user', type: 'message' })
      }
      catch {
        if (state.timer != null)
          clearTimeout(state.timer)
        sessions.delete(state.sessionId)
        return
      }

      scheduleTick(state)
    }, interval)

Comment on lines +16 to +25
const sleepTool = tool({
description: 'Enter a resting state. Call this when there is nothing useful to do after a tick. This conserves tokens and respects the user\'s attention.',
execute: (input: unknown) => {
const args = z.parse(sleepSchema, input)
callbacks.onSleep()
return `Sleeping for ${args.duration}. Next tick will arrive after that. Reason: ${args.reason ?? 'idle'}`
},
name: 'sleep',
parameters: sleepSchema,
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The sleep tool defines a duration parameter in its schema (e.g., "5m", "30s", "1h"), but the implementation completely ignores this value and always transitions the agent to the fixed 'resting' state (which has a hardcoded 5-minute interval). This is misleading to the model.

Consider either:

  1. Parsing the duration string (e.g., using a simple regex or utility to convert "5m" to milliseconds) and dynamically scheduling the next tick interval.
  2. Removing the duration parameter from the schema and updating the tool description to state that the sleep duration is fixed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant