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
11 changes: 8 additions & 3 deletions docs/STATE-MACHINES.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Tasks and requirements share a **single status enum** (`ItemStatus`). Not every
| `failed` | `in_progress` | User clicks Retry (fresh start, new round) | `retryTaskFresh()` |
| `failed` | `archived` | Manual archival | `archiveTask()` |
| `completed` | `archived` | Manual archival | `archiveTask()` |
| `cancelled` | `in_progress` | Run Now (scheduled tasks) | `resetTaskForRerun()` |
| `cancelled` | `archived` | Manual archival | `archiveTask()` |
| `rejected` | `archived` | Manual archival | `archiveTask()` |

Expand Down Expand Up @@ -98,7 +99,7 @@ The legal transition set is defined as a declarative matrix `TASK_TRANSITIONS` i
6. **Rejected ≠ Cancelled**: `rejectTask()` sets `rejected` (proposal denied before work). `cancelTask()` sets `cancelled` (work stopped after starting). `rejected` is a terminal state — the proposal was not approved.
7. **Preemption ≠ blocked**: When the attention controller **preempts** (pauses) a task for a higher-priority item, the task stays `in_progress` (not `blocked`). The mailbox item is deferred and automatically resurfaced when the agent is idle. The same execution round and session context are preserved. **Cancellation** is different: when the controller **cancels** a task (because the incoming message explicitly revokes the work), the mailbox item is permanently dropped and will NOT be resumed.
8. **Irreversible actions require confirmation**: The UI requires explicit user confirmation (via modal dialogs) for Reject, Cancel (always, regardless of dependents), and Archive operations. This prevents accidental data loss.
9. **Scheduled task button restrictions**: Completed scheduled tasks do not show "Reopen" (the schedule handles re-runs). Failed scheduled tasks show only "Run Now" (no "Retry"/"Continue" since the scheduler manages retries).
9. **Scheduled task button restrictions**: Completed scheduled tasks do not show "Reopen" (the schedule handles re-runs). Failed scheduled tasks show only "Run Now" (no "Retry"/"Continue" since the scheduler manages retries). Cancelled scheduled tasks show "Run Now" and "Resume Schedule" to allow re-activation.
10. **Human-created tasks**: Created as `pending` with no HITL approval request and no `task_created` notification (no self-notification). The UI shows "Start Execution" instead of "Approve" for human-created pending tasks. Agent-created tasks show "Approve" and trigger HITL approval flow.

---
Expand Down Expand Up @@ -148,9 +149,10 @@ After `completed`, the `ScheduledTaskRunner` checks `nextRunAt`. When it's time,
1. **`ScheduledTaskRunner`** polls every 60 seconds.
2. Checks `nextRunAt` against current time.
3. Fires only when task is in a "fireable" state (`completed`, `failed`) and not paused.
4. Tasks in `in_progress`, `review`, `blocked`, `pending` are **skipped** (a run is active).
4. Tasks in `in_progress`, `review`, `blocked`, `pending`, `cancelled`, `archived`, `rejected` are **skipped**.
5. `config.paused = true` skips the task.
6. `maxRuns` limit stops scheduling when `currentRuns >= maxRuns`.
7. **Cancelling** a scheduled task automatically pauses its schedule (`config.paused = true`). The user can later resume the schedule or use "Run Now" to manually trigger execution.

### Schedule Configuration Editing

Expand Down Expand Up @@ -268,10 +270,13 @@ Both paths apply identical notification logic (dedup, self-skip, stakeholder aut
When a task is cancelled:
1. Running execution is stopped (cancel token)
2. Status → `cancelled`
3. Dependent tasks are checked:
3. If the task is a scheduled task, the schedule is automatically paused (`scheduleConfig.paused = true`)
4. Dependent tasks are checked:
- If the cancelled task was a dependent's **only** remaining blocker, the dependent unblocks
- If the dependent has other incomplete blockers, it stays `blocked`

A cancelled scheduled task can be re-activated via "Run Now" (`cancelled → in_progress`) or by resuming the schedule after un-cancelling.

### Cascade Cancellation

User can choose to cascade-cancel all dependent tasks:
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"clean": "rm -rf dist *.tsbuildinfo templates/"
},
"dependencies": {
"@larksuiteoapi/lark-mcp": "^0.5.1",
"ws": "^8.19.0"
},
"optionalDependencies": {
Expand Down
39 changes: 38 additions & 1 deletion packages/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { BackendInstance } from '../backend.js';
import { resolve, join, dirname, delimiter } from 'node:path';
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { createRequire } from 'node:module';
import { allTemplateDirs, resolveTemplatesDir, resolveWebUiDir } from '../paths.js';
import {
loadConfig,
Expand Down Expand Up @@ -325,14 +326,50 @@ export async function createServices(config: ReturnType<typeof loadConfig>) {
taskService.startTimeoutChecker();
}

// Inject Feishu MCP server whenever credentials are available
const mcpServers = { ...config.mcpServers };
const feishuIntegration = config.integrations?.feishu;
const feishuAppId = feishuIntegration?.appId ?? process.env['FEISHU_APP_ID'];
const feishuAppSecret = feishuIntegration?.appSecret ?? process.env['FEISHU_APP_SECRET'];
if (feishuAppId && feishuAppSecret) {
const presets = feishuIntegration?.mcp?.presets ?? ['preset.default'];
let larkMcpBin: string;
try {
const esmRequire = createRequire(import.meta.url);
const pkgPath = esmRequire.resolve('@larksuiteoapi/lark-mcp/package.json');
larkMcpBin = join(dirname(pkgPath), 'dist', 'cli.js');
} catch {
larkMcpBin = '';
}
const baseArgs = [
'mcp',
'-a', feishuAppId,
'-s', feishuAppSecret,
'-t', presets.join(','),
'--token-mode', 'tenant_access_token',
];
if (larkMcpBin && existsSync(larkMcpBin)) {
mcpServers['feishu-lark'] = {
command: 'node',
args: [larkMcpBin, ...baseArgs],
};
} else {
mcpServers['feishu-lark'] = {
command: 'npx',
args: ['-y', '@larksuiteoapi/lark-mcp', ...baseArgs],
};
}
log.info('Feishu MCP server configured', { presets, localBin: !!larkMcpBin });
}

const agentManager = new AgentManager({
llmRouter,
roleLoader,
dataDir: join(markusDataDir, 'agents'),
sharedDataDir,
skillRegistry,
taskService,
mcpServers: config.mcpServers,
mcpServers,
});

if (config.agent?.maxToolIterations) {
Expand Down
9 changes: 7 additions & 2 deletions packages/comms/src/feishu/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,13 @@ export class FeishuAdapter implements CommAdapter {
} else {
const port = this.config.webhookPort ?? 9000;
this.server = createServer((req, res) => this.handleWebhook(req, res));
this.server.listen(port, () => {
log.info(`Feishu webhook server listening on port ${port}`);
await new Promise<void>((resolve, reject) => {
this.server!.once('error', reject);
this.server!.listen(port, () => {
this.server!.removeListener('error', reject);
log.info(`Feishu webhook server listening on port ${port}`);
resolve();
});
});
}

Expand Down
82 changes: 82 additions & 0 deletions packages/comms/src/feishu/cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,85 @@ export function buildProgressCard(opts: {
elements,
};
}

// ── Agent Response Cards (Phase 2: Streaming Status) ───────────────────

export type AgentCardPhase = 'thinking' | 'tool_calling' | 'responding' | 'done' | 'error';

export interface ToolCallEntry {
name: string;
status: 'running' | 'done' | 'error';
durationMs?: number;
}

/**
* Build an interactive card showing agent processing status.
* This card is sent once and then updated in-place as the agent progresses.
*/
export function buildAgentResponseCard(opts: {
agentName: string;
phase: AgentCardPhase;
toolCalls?: ToolCallEntry[];
content?: string;
errorMessage?: string;
elapsedMs?: number;
}) {
const { agentName, phase, toolCalls, content, errorMessage, elapsedMs } = opts;

const headerMap: Record<AgentCardPhase, { title: string; template: string }> = {
thinking: { title: `💭 ${agentName} 正在思考...`, template: 'blue' },
tool_calling: { title: `🔧 ${agentName} 正在执行...`, template: 'blue' },
responding: { title: `✍️ ${agentName} 正在回复...`, template: 'blue' },
done: { title: `✅ ${agentName}`, template: 'green' },
error: { title: `❌ ${agentName} 处理失败`, template: 'red' },
};

const header = headerMap[phase];
const elements: unknown[] = [];

if (toolCalls?.length) {
const toolLines = toolCalls.map(tc => {
if (tc.status === 'running') return `⏳ 正在调用 \`${tc.name}\`...`;
if (tc.status === 'error') return `❌ \`${tc.name}\` 失败`;
const dur = tc.durationMs !== null && tc.durationMs !== undefined ? ` (${tc.durationMs}ms)` : '';
return `✅ \`${tc.name}\` 完成${dur}`;
}).join('\n');
elements.push({ tag: 'div', text: { tag: 'lark_md', content: toolLines } });
}

if (phase === 'thinking' && !toolCalls?.length && !content) {
elements.push({ tag: 'div', text: { tag: 'lark_md', content: '正在分析您的消息...' } });
}

if (content) {
if (toolCalls?.length) {
elements.push({ tag: 'hr' });
}
elements.push({ tag: 'div', text: { tag: 'lark_md', content } });
}

if (errorMessage) {
elements.push({ tag: 'div', text: { tag: 'lark_md', content: `**错误:** ${errorMessage}` } });
}

if (phase === 'done' && elapsedMs !== null && elapsedMs !== undefined) {
const seconds = (elapsedMs / 1000).toFixed(1);
elements.push({
tag: 'note',
elements: [{ tag: 'plain_text', content: `耗时 ${seconds}s` }],
});
}

if (elements.length === 0) {
elements.push({ tag: 'div', text: { tag: 'lark_md', content: '...' } });
}

return {
config: { wide_screen_mode: true, update_multi: true },
header: {
title: { tag: 'plain_text', content: header.title },
template: header.template,
},
elements,
};
}
3 changes: 2 additions & 1 deletion packages/comms/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { FeishuAdapter } from './feishu/adapter.js';
export { FeishuClient } from './feishu/client.js';
export { buildStatusCard, buildTaskCard, buildProgressCard } from './feishu/cards.js';
export { buildStatusCard, buildTaskCard, buildProgressCard, buildAgentResponseCard } from './feishu/cards.js';
export type { AgentCardPhase, ToolCallEntry } from './feishu/cards.js';
export { WebUIAdapter } from './webui/adapter.js';
export { WhatsAppAdapter } from './whatsapp/adapter.js';
export { WhatsAppClient } from './whatsapp/client.js';
Expand Down
56 changes: 56 additions & 0 deletions packages/core/src/agent-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ export interface TaskServiceBridge {
rejectTask(id: string, userId?: string): { id: string; title: string; status: string };
addSubtask(taskId: string, title: string): { id: string; title: string; status: string };
completeSubtask(taskId: string, subtaskId: string): { id: string; title: string; status: string };
cancelSubtask(taskId: string, subtaskId: string): { id: string; title: string; status: string };
getSubtasks?(taskId: string): Array<{ id: string; title: string; status: string }>;
submitForReview(taskId: string, deliverables: Array<{ type: string; reference: string; summary: string; diffStats?: unknown; testResults?: unknown }>, reviewerId?: string, completionSummary?: string): Promise<{ id: string; status: string }>;
requestRevision(taskId: string, reason: string, author?: string): Promise<{ id: string; title: string; status: string }>;
Expand Down Expand Up @@ -1075,6 +1076,17 @@ export class AgentManager {
const skill = this.skillRegistry?.get(skillName);
const isolation = skill?.manifest.isolation ?? 'shared';
for (const [serverName, rawSrvConfig] of Object.entries(mcpServers)) {
// reuseGlobal: reuse the globally-configured MCP server (with real credentials from config)
if ((rawSrvConfig as Record<string, unknown>).reuseGlobal === true) {
const globalConfig = this.globalMcpServers?.[serverName];
if (globalConfig) {
const gc = this.enrichChromeDevtoolsConfig(serverName, globalConfig);
await this.mcpManager.connectServer(serverName, gc);
}
const handlers = this.mcpManager.getToolHandlers(serverName);
if (handlers.length > 0) tools.push(...handlers);
continue;
}
const srvConfig = this.enrichChromeDevtoolsConfig(serverName, rawSrvConfig);
if (serverName === 'chrome-devtools') {
const chromeTools = await this.registerChromeDevtoolsLazy(id, serverName, srvConfig);
Expand Down Expand Up @@ -1294,6 +1306,9 @@ export class AgentManager {
completeSubtask: async (taskId, subtaskId) => {
return ts.completeSubtask(taskId, subtaskId);
},
cancelSubtask: async (taskId, subtaskId) => {
return ts.cancelSubtask(taskId, subtaskId);
},
getSubtasks: async (taskId) => {
const task = ts.getTask(taskId);
return task?.subtasks ?? [];
Expand Down Expand Up @@ -1897,12 +1912,50 @@ export class AgentManager {
void Promise.all(mcpConnections);
}

// Connect global MCP servers (e.g. feishu-lark) for booted agents — same as createAgentFromRequest
if (this.globalMcpServers) {
for (const [serverName, rawServerConfig] of Object.entries(this.globalMcpServers)) {
if (agent.hasToolPrefix(serverName)) continue; // already connected via skill
void (async () => {
try {
const serverConfig = this.enrichChromeDevtoolsConfig(serverName, rawServerConfig);
await this.mcpManager.connectServer(serverName, serverConfig);
const mcpTools = this.mcpManager.getToolHandlers(serverName);
const toolNames: string[] = [];
for (const tool of mcpTools) {
agent.registerTool(tool);
toolNames.push(tool.name);
}
agent.activateTools(toolNames);
log.info(`Global MCP server ${serverName} connected for booted agent ${id}`, {
toolCount: mcpTools.length,
});
} catch (error) {
log.warn(`Failed to connect global MCP server ${serverName} for booted agent ${id}`, {
error: String(error),
});
}
})();
}
}

// Set skill MCP activator callback for runtime activation via discover_tools
agent.setSkillMcpActivator(async (skillName, mcpServers) => {
let tools: AgentToolHandler[] = [];
const skill = this.skillRegistry?.get(skillName);
const isolation = skill?.manifest.isolation ?? 'shared';
for (const [serverName, rawSrvConfig] of Object.entries(mcpServers)) {
// reuseGlobal: reuse the globally-configured MCP server (with real credentials from config)
if ((rawSrvConfig as Record<string, unknown>).reuseGlobal === true) {
const globalConfig = this.globalMcpServers?.[serverName];
if (globalConfig) {
const gc = this.enrichChromeDevtoolsConfig(serverName, globalConfig);
await this.mcpManager.connectServer(serverName, gc);
}
const handlers = this.mcpManager.getToolHandlers(serverName);
if (handlers.length > 0) tools.push(...handlers);
continue;
}
const srvConfig = this.enrichChromeDevtoolsConfig(serverName, rawSrvConfig);
if (serverName === 'chrome-devtools') {
const chromeTools = await this.registerChromeDevtoolsLazy(id, serverName, srvConfig);
Expand Down Expand Up @@ -2088,6 +2141,9 @@ export class AgentManager {
completeSubtask: async (taskId, subtaskId) => {
return ts.completeSubtask(taskId, subtaskId);
},
cancelSubtask: async (taskId, subtaskId) => {
return ts.cancelSubtask(taskId, subtaskId);
},
getSubtasks: async (taskId) => {
const task = ts.getTask(taskId);
return task?.subtasks ?? [];
Expand Down
Loading
Loading