Skip to content

Commit 61bf242

Browse files
RoyLinRoyLin
authored andcommitted
feat: add /loop slash command, cron scheduler, and SDK scheduled task APIs (v1.2.2)
- Add CronScheduler: session-scoped recurring prompt scheduler with lazy-start background ticker (no reactor required at session creation time) - Add /loop, /cron-list, /cron-cancel slash commands - Expose listCommands / list_commands in Node.js and Python SDKs - Expose scheduleTask / schedule_task (programmatic scheduler API) in SDKs - Expose listScheduledTasks / list_scheduled_tasks in SDKs - Expose cancelScheduledTask / cancel_scheduled_task in SDKs - Add register_command in Python SDK for custom slash command handlers - Add test_loop_commands.py and test_loop_commands.ts integration examples - Update docs and README: test count 1458->1500, slash commands and scheduled tasks sections in sessions.mdx (en + cn)
1 parent 1b5dad6 commit 61bf242

12 files changed

Lines changed: 1073 additions & 65 deletions

File tree

README.md

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ let result = session.send("Refactor auth to use JWT").await?;
1111
[![Crates.io](https://img.shields.io/crates/v/a3s-code-core.svg)](https://crates.io/crates/a3s-code-core)
1212
[![Documentation](https://docs.rs/a3s-code-core/badge.svg)](https://docs.rs/a3s-code-core)
1313
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
14-
[![Tests](https://img.shields.io/badge/tests-1458%20passing-brightgreen.svg)](./core/tests)
14+
[![Tests](https://img.shields.io/badge/tests-1500%20passing-brightgreen.svg)](./core/tests)
1515

1616
---
1717

@@ -255,6 +255,38 @@ Interactive session commands dispatched before the LLM. Custom commands via the
255255
| `/cron-list` | List all scheduled recurring prompts |
256256
| `/cron-cancel <id>` | Cancel a scheduled task by ID |
257257

258+
### Programmatic Scheduler API (SDK)
259+
260+
```typescript
261+
// TypeScript
262+
const commands = session.listCommands(); // CommandInfo[]
263+
264+
const taskId = session.scheduleTask('check deployment status', 30); // every 30s
265+
const tasks = session.listScheduledTasks(); // ScheduledTaskInfo[]
266+
const ok = session.cancelScheduledTask(taskId); // boolean
267+
268+
// Also via slash commands:
269+
await session.send('/loop 5m summarize recent commits');
270+
await session.send('/cron-list');
271+
await session.send(`/cron-cancel ${taskId}`);
272+
```
273+
274+
```python
275+
# Python
276+
commands = session.list_commands() # list[dict] — name, description, usage
277+
session.register_command("status", "Show session status",
278+
lambda args, ctx: f"Model: {ctx['model']}, History: {ctx['history_len']} msgs")
279+
280+
task_id = session.schedule_task("check deployment status", 30) # every 30s
281+
tasks = session.list_scheduled_tasks() # list[dict] — id, prompt, interval_secs, fire_count, next_fire_in_secs
282+
ok = session.cancel_scheduled_task(task_id) # True
283+
284+
# Also via slash commands:
285+
session.send("/loop 5m summarize recent commits")
286+
session.send("/cron-list")
287+
session.send(f"/cron-cancel {task_id}")
288+
```
289+
258290
```rust
259291
use a3s_code_core::commands::{SlashCommand, CommandContext, CommandOutput};
260292

@@ -947,7 +979,8 @@ SessionOptions::new().with_security_provider(Arc::new(MyProvider))
947979

948980
```
949981
Agent (config-driven)
950-
├── CommandRegistry (slash commands: /help, /cost, /model, /clear, ...)
982+
├── CommandRegistry (slash commands: /help, /model, /loop, /cron-list, /cron-cancel, ...)
983+
│ └── CronScheduler (session-scoped recurring prompts, lazy-start background ticker)
951984
└── AgentSession (workspace-bound)
952985
├── AgentLoop (core execution engine)
953986
│ ├── ToolExecutor (13 built-in tools, batch parallel execution)
@@ -1082,6 +1115,10 @@ let id = session.session_id();
10821115
// Slash commands
10831116
session.register_command(Arc::new(MyCommand));
10841117
let registry = session.command_registry();
1118+
for (name, desc, usage) in registry.list_full() { ... }
1119+
1120+
// Scheduled tasks (programmatic)
1121+
// (Use /loop, /cron-list, /cron-cancel slash commands from send() — or via SDK:)
10851122

10861123
// Skills (dynamic load by name — must be a built-in or loaded from skill_dirs)
10871124
session.load_skill("delegate-task");
@@ -1220,9 +1257,24 @@ result = session.tool("git_worktree", {"command": "list"})
12201257
session.remember_success("task", ["tool"], "result")
12211258
items = session.recall_similar("auth", 5)
12221259

1260+
# Slash commands
1261+
commands = session.list_commands() # list[dict] — name, description, usage
1262+
session.register_command("status", "Show session status",
1263+
lambda args, ctx: f"Model: {ctx['model']}, History: {ctx['history_len']} msgs")
1264+
result = session.send("/status")
1265+
12231266
# Hooks
12241267
session.register_hook("audit", "pre_tool_use", handler_fn)
12251268

1269+
# Scheduled tasks (programmatic)
1270+
task_id = session.schedule_task("check deployment status", 30) # every 30s
1271+
tasks = session.list_scheduled_tasks() # list[dict] — id, prompt, interval_secs, fire_count, next_fire_in_secs
1272+
ok = session.cancel_scheduled_task(task_id) # True
1273+
# Also via slash commands:
1274+
session.send("/loop 5m summarize recent commits")
1275+
session.send("/cron-list")
1276+
session.send(f"/cron-cancel {task_id}")
1277+
12261278
# MCP management
12271279
count = session.add_mcp_server("filesystem", command="npx", args=["-y", "@modelcontextprotocol/server-filesystem", "/tmp"])
12281280
status = session.mcp_status() # {name: {connected, tool_count, error}}
@@ -1313,6 +1365,18 @@ const items = await session.recallSimilar('auth', 5);
13131365
// Hooks
13141366
session.registerHook('audit', 'pre_tool_use', handlerFn);
13151367

1368+
// Slash commands
1369+
const commands = session.listCommands(); // CommandInfo[]
1370+
1371+
// Scheduled tasks (programmatic)
1372+
const taskId = session.scheduleTask('check deployment status', 30); // every 30s
1373+
const tasks = session.listScheduledTasks(); // ScheduledTaskInfo[]
1374+
const ok = session.cancelScheduledTask(taskId); // boolean
1375+
// Also via slash commands:
1376+
await session.send('/loop 5m summarize recent commits');
1377+
await session.send('/cron-list');
1378+
await session.send(`/cron-cancel ${taskId}`);
1379+
13161380
// MCP management
13171381
const count = await session.addMcpServer('filesystem', 'stdio', 'npx', ['-y', '@modelcontextprotocol/server-filesystem', '/tmp']);
13181382
const status = await session.mcpStatus(); // McpServerStatusEntry[]
@@ -1384,6 +1448,8 @@ cargo run --example 02_streaming
13841448
| Node.js | `sdk/node/examples/test_run_team_kimi.ts` | Orchestrator.runTeam() with AgentSlot array (Lead/Worker/Reviewer) |
13851449
| Node.js | `sdk/node/examples/test_run_team_tool.ts` | `run_team` built-in tool via session.tool() + LLM-driven |
13861450
| Node.js | `sdk/node/examples/test_run_team_tool_kimi.ts` | `run_team` tool smoke test with external LLM |
1451+
| Python | `sdk/python/examples/test_loop_commands.py` | Slash commands: /loop, /cron-list, /cron-cancel, list_commands, register_command, schedule_task |
1452+
| Node.js | `sdk/node/examples/test_loop_commands.ts` | Slash commands: /loop, /cron-list, /cron-cancel, listCommands, scheduleTask |
13871453

13881454
### Integration & Feature Tests
13891455

@@ -1412,7 +1478,7 @@ cargo test # All tests
14121478
cargo test --lib # Unit tests only
14131479
```
14141480

1415-
**Test Coverage:** 1458 tests, 100% pass rate
1481+
**Test Coverage:** 1500 tests, 100% pass rate
14161482

14171483
---
14181484

core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-code-core"
3-
version = "1.2.1"
3+
version = "1.2.2"
44
edition = "2021"
55
authors = ["A3S Lab Team"]
66
license = "MIT"

core/src/agent_api.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1202,8 +1202,9 @@ impl Agent {
12021202
};
12031203

12041204
// Build the cron scheduler and register scheduler-backed commands.
1205+
// The background ticker is started lazily on the first send() call
1206+
// to ensure tokio::spawn is called from within an async context.
12051207
let (cron_scheduler, cron_rx) = CronScheduler::new();
1206-
CronScheduler::start(Arc::clone(&cron_scheduler));
12071208
let mut command_registry = CommandRegistry::new();
12081209
command_registry.register(Arc::new(LoopCommand {
12091210
scheduler: Arc::clone(&cron_scheduler),
@@ -1243,6 +1244,7 @@ impl Agent {
12431244
cron_scheduler,
12441245
cron_rx: tokio::sync::Mutex::new(cron_rx),
12451246
is_processing_cron: AtomicBool::new(false),
1247+
cron_started: AtomicBool::new(false),
12461248
})
12471249
}
12481250
}
@@ -1290,6 +1292,10 @@ pub struct AgentSession {
12901292
cron_rx: tokio::sync::Mutex<mpsc::UnboundedReceiver<ScheduledFire>>,
12911293
/// Guard: prevents nested cron processing when a scheduled prompt itself calls send().
12921294
is_processing_cron: AtomicBool,
1295+
/// Whether the background cron ticker has been started.
1296+
/// The ticker is started lazily on the first `send()` call so that
1297+
/// `tokio::spawn` is always called from within an async runtime context.
1298+
cron_started: AtomicBool,
12931299
}
12941300

12951301
impl std::fmt::Debug for AgentSession {
@@ -1382,6 +1388,16 @@ impl AgentSession {
13821388
&self.cron_scheduler
13831389
}
13841390

1391+
/// Start the background cron ticker if it hasn't been started yet.
1392+
///
1393+
/// Must be called from within an async context (inside `tokio::spawn` or
1394+
/// equivalent) so that `tokio::spawn` inside `CronScheduler::start` succeeds.
1395+
fn ensure_cron_started(&self) {
1396+
if !self.cron_started.swap(true, Ordering::Relaxed) {
1397+
CronScheduler::start(Arc::clone(&self.cron_scheduler));
1398+
}
1399+
}
1400+
13851401
/// Send a prompt and wait for the complete response.
13861402
///
13871403
/// When `history` is `None`, uses (and auto-updates) the session's
@@ -1391,6 +1407,10 @@ impl AgentSession {
13911407
/// If the prompt starts with `/`, it is dispatched as a slash command
13921408
/// and the result is returned without calling the LLM.
13931409
pub async fn send(&self, prompt: &str, history: Option<&[Message]>) -> Result<AgentResult> {
1410+
// Lazily start the cron background ticker on the first send() — we are
1411+
// guaranteed to be inside a tokio async context here.
1412+
self.ensure_cron_started();
1413+
13941414
// Slash command interception
13951415
if CommandRegistry::is_command(prompt) {
13961416
let ctx = self.build_command_context();
@@ -1541,6 +1561,8 @@ impl AgentSession {
15411561
prompt: &str,
15421562
history: Option<&[Message]>,
15431563
) -> Result<(mpsc::Receiver<AgentEvent>, JoinHandle<()>)> {
1564+
self.ensure_cron_started();
1565+
15441566
// Slash command interception for streaming
15451567
if CommandRegistry::is_command(prompt) {
15461568
let ctx = self.build_command_context();

sdk/node/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-code-node"
3-
version = "1.2.1"
3+
version = "1.2.2"
44
edition = "2021"
55
authors = ["A3S Lab Team"]
66
license = "MIT"
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* Test /loop and related slash commands via Node.js SDK.
3+
* Uses the kimi model from ~/.a3s/config.hcl.
4+
*/
5+
6+
import { Agent } from "@a3s-lab/code";
7+
import { existsSync } from "fs";
8+
import { homedir } from "os";
9+
import { join } from "path";
10+
import { fileURLToPath } from "url";
11+
import { dirname } from "path";
12+
13+
const __filename = fileURLToPath(import.meta.url);
14+
const __dirname = dirname(__filename);
15+
16+
function findConfig(): string {
17+
const candidates = [
18+
join(homedir(), ".a3s", "config.hcl"),
19+
join(__dirname, "..", "..", "..", "..", "..", ".a3s", "config.hcl"),
20+
];
21+
for (const p of candidates) {
22+
if (existsSync(p)) return p;
23+
}
24+
throw new Error(`config.hcl not found in: ${candidates.join(", ")}`);
25+
}
26+
27+
const CONFIG_PATH = findConfig();
28+
29+
function sep(title: string) {
30+
console.log("=".repeat(60));
31+
console.log(title);
32+
console.log("=".repeat(60));
33+
}
34+
35+
async function main() {
36+
console.log(`Config: ${CONFIG_PATH}\n`);
37+
38+
const agent = await Agent.create(CONFIG_PATH);
39+
const session = agent.session("/tmp/test-loop-node");
40+
41+
// ── 1. listCommands() ──────────────────────────────────────────
42+
sep("1. listCommands() — all registered slash commands");
43+
const commands = session.listCommands();
44+
for (const cmd of commands) {
45+
const usage = cmd.usage ? ` usage: ${cmd.usage}` : "";
46+
console.log(` /${cmd.name.padEnd(15)} ${cmd.description}${usage}`);
47+
}
48+
console.log();
49+
50+
// ── 2. /help via send() ────────────────────────────────────────
51+
sep("2. /help via send()");
52+
{
53+
const r = await session.send("/help");
54+
console.log(r.text);
55+
}
56+
console.log();
57+
58+
// ── 3. /loop via send() ────────────────────────────────────────
59+
sep("3. /loop via send() — schedule a 10-second recurring prompt");
60+
{
61+
const r = await session.send("/loop 10s say 'tick' and report the time");
62+
console.log(r.text);
63+
}
64+
console.log();
65+
66+
// ── 4. scheduleTask() — programmatic API ──────────────────────
67+
sep("4. scheduleTask() — programmatic API (15s interval)");
68+
const taskId = session.scheduleTask("print current working directory", 15);
69+
console.log(`Scheduled task ID: ${taskId}`);
70+
console.log();
71+
72+
// ── 5. listScheduledTasks() + /cron-list ──────────────────────
73+
sep("5. listScheduledTasks() + /cron-list");
74+
const tasks = session.listScheduledTasks();
75+
console.log(`Active tasks: ${tasks.length}`);
76+
for (const t of tasks) {
77+
console.log(
78+
` [${t.id}] every ${t.intervalSecs}s — fires in ${t.nextFireInSecs}s — "${t.prompt.slice(0, 50)}"`
79+
);
80+
}
81+
console.log();
82+
{
83+
const r = await session.send("/cron-list");
84+
console.log("/cron-list output:");
85+
console.log(r.text);
86+
}
87+
console.log();
88+
89+
// ── 6. Real LLM turn (exercises cron drain path) ──────────────
90+
sep("6. Real LLM turn — exercises cron drain path");
91+
{
92+
const r = await session.send("Say hello in one sentence.");
93+
console.log(`LLM: ${r.text.slice(0, 200)}`);
94+
}
95+
console.log();
96+
97+
// ── 7. /model + /cost + /history (other slash commands) ───────
98+
sep("7. Other slash commands: /model, /cost, /history");
99+
for (const cmd of ["/model", "/cost", "/history"]) {
100+
const r = await session.send(cmd);
101+
console.log(`${cmd}: ${r.text.split("\n")[0]}`);
102+
}
103+
console.log();
104+
105+
// ── 8. cancelScheduledTask() + /cron-cancel ───────────────────
106+
sep("8. cancelScheduledTask() + /cron-cancel");
107+
const cancelled = session.cancelScheduledTask(taskId);
108+
console.log(`cancelScheduledTask(${taskId}): ${cancelled}`);
109+
110+
const remaining = session.listScheduledTasks();
111+
if (remaining.length > 0) {
112+
const loopId = remaining[0].id;
113+
const r = await session.send(`/cron-cancel ${loopId}`);
114+
console.log(`/cron-cancel ${loopId}: ${r.text}`);
115+
}
116+
console.log();
117+
118+
// ── 9. Verify empty ───────────────────────────────────────────
119+
sep("9. Verify all tasks cancelled");
120+
const tasksAfter = session.listScheduledTasks();
121+
console.log(`Remaining tasks: ${tasksAfter.length}`);
122+
{
123+
const r = await session.send("/cron-list");
124+
console.log(r.text);
125+
}
126+
console.log();
127+
128+
sep("All tests passed!");
129+
}
130+
131+
main().catch((e) => {
132+
console.error("Test failed:", e);
133+
process.exit(1);
134+
});

0 commit comments

Comments
 (0)