mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
feat: add model and turn control commands
This commit is contained in:
parent
fc8f120e13
commit
99ce38e9a4
38 changed files with 3451 additions and 169 deletions
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
|
||||
|
||||
function harnessOrigin(webServer, configured) {
|
||||
if (configured !== undefined) return new URL(configured);
|
||||
|
|
@ -81,6 +82,10 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
return state;
|
||||
};
|
||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
||||
controlExecutor: internals.controlExecutor,
|
||||
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
||||
});
|
||||
const harness = new Harness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
workspace: defaultWorkspace,
|
||||
|
|
@ -88,6 +93,8 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
...(commandExecutor ? { commandExecutor } : {}),
|
||||
...(controlExecutor ? { controlExecutor } : {}),
|
||||
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
||||
});
|
||||
const coreController = new Controller({
|
||||
deviceAuth,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { unlink } from 'node:fs/promises';
|
|||
import * as Lark from '@larksuiteoapi/node-sdk';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
|
||||
import { verifyFeishuApp } from '../../../../src/channels/feishu/feishu-app.mjs';
|
||||
import { FeishuRuntime } from '../../../../src/channels/feishu/feishu-runtime.mjs';
|
||||
import { HarnessClient } from '../../../../src/channels/feishu/harness-client.mjs';
|
||||
|
|
@ -107,6 +108,10 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
return stateFor(botConfig);
|
||||
};
|
||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
||||
controlExecutor: internals.controlExecutor,
|
||||
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
||||
});
|
||||
const harness = new Harness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
workspace: defaultWorkspace,
|
||||
|
|
@ -116,6 +121,8 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
...(commandExecutor ? { commandExecutor } : {}),
|
||||
...(controlExecutor ? { controlExecutor } : {}),
|
||||
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
||||
});
|
||||
|
||||
const coreController = new Controller({
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
|
||||
|
||||
function harnessOrigin(webServer, configured) {
|
||||
if (configured !== undefined) return new URL(configured);
|
||||
|
|
@ -72,6 +73,10 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
return state;
|
||||
};
|
||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
||||
controlExecutor: internals.controlExecutor,
|
||||
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
||||
});
|
||||
const harness = new Harness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
workspace: defaultWorkspace,
|
||||
|
|
@ -79,6 +84,8 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
...(commandExecutor ? { commandExecutor } : {}),
|
||||
...(controlExecutor ? { controlExecutor } : {}),
|
||||
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
||||
});
|
||||
const coreController = new Controller({
|
||||
qrAuth,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { join, resolve } from 'node:path';
|
|||
|
||||
import { createTokenConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
|
||||
import {
|
||||
BotWorkspaceStore,
|
||||
createBotWorkspaceScope,
|
||||
|
|
@ -66,6 +67,10 @@ export async function createTokenProductionController(ctx, config, internals, de
|
|||
return state;
|
||||
};
|
||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
||||
controlExecutor: internals.controlExecutor,
|
||||
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
||||
});
|
||||
const harness = new ResolvedHarness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
workspace: defaultWorkspace,
|
||||
|
|
@ -73,6 +78,8 @@ export async function createTokenProductionController(ctx, config, internals, de
|
|||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
...(commandExecutor ? { commandExecutor } : {}),
|
||||
...(controlExecutor ? { controlExecutor } : {}),
|
||||
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
||||
});
|
||||
const coreController = new ResolvedController({
|
||||
credentials: ctx.credentials,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
import { createTokenConnectionSupervisor } from '../shared/connection-supervisor.mjs';
|
||||
import { harnessOrigin, pluginPaths } from '../shared/production.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
|
||||
|
||||
export async function createProductionController(ctx, config = {}, internals = {}) {
|
||||
if (!ctx?.credentials) throw new TypeError('dsh-im slack requires ctx.credentials');
|
||||
|
|
@ -51,6 +52,10 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
return state;
|
||||
};
|
||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
||||
controlExecutor: internals.controlExecutor,
|
||||
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
||||
});
|
||||
const harness = new ResolvedHarness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
workspace: defaultWorkspace,
|
||||
|
|
@ -58,6 +63,8 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
...(commandExecutor ? { commandExecutor } : {}),
|
||||
...(controlExecutor ? { controlExecutor } : {}),
|
||||
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
||||
});
|
||||
const coreController = new ResolvedController({
|
||||
credentials: ctx.credentials,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
|
||||
|
||||
function harnessOrigin(webServer, configured) {
|
||||
if (configured !== undefined) return new URL(configured);
|
||||
|
|
@ -75,6 +76,10 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
return state;
|
||||
};
|
||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
||||
controlExecutor: internals.controlExecutor,
|
||||
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
||||
});
|
||||
const harness = new Harness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
workspace: defaultWorkspace,
|
||||
|
|
@ -82,6 +87,8 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
...(commandExecutor ? { commandExecutor } : {}),
|
||||
...(controlExecutor ? { controlExecutor } : {}),
|
||||
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
||||
});
|
||||
const coreController = new Controller({
|
||||
qrAuth,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
|
||||
|
||||
function harnessOrigin(webServer, configured) {
|
||||
if (configured !== undefined) return new URL(configured);
|
||||
|
|
@ -75,6 +76,10 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
return state;
|
||||
};
|
||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
||||
controlExecutor: internals.controlExecutor,
|
||||
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
||||
});
|
||||
const harness = new Harness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
workspace: defaultWorkspace,
|
||||
|
|
@ -82,6 +87,8 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
...(commandExecutor ? { commandExecutor } : {}),
|
||||
...(controlExecutor ? { controlExecutor } : {}),
|
||||
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
||||
});
|
||||
const coreController = new Controller({
|
||||
api,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { createTokenConnectionSupervisor } from '../shared/connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
|
||||
|
||||
const AUTH_DIRECTORY_PATTERN = /^[a-f0-9-]{36}$/;
|
||||
|
||||
|
|
@ -78,6 +79,10 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
return state;
|
||||
};
|
||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
||||
controlExecutor: internals.controlExecutor,
|
||||
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
||||
});
|
||||
const harness = new Harness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
workspace: defaultWorkspace,
|
||||
|
|
@ -85,6 +90,8 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
...(commandExecutor ? { commandExecutor } : {}),
|
||||
...(controlExecutor ? { controlExecutor } : {}),
|
||||
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
||||
});
|
||||
const coreController = new Controller({
|
||||
configStore: observedConfigStore,
|
||||
|
|
|
|||
134
plugin-src/host/harness-session-coordinator.mjs
Normal file
134
plugin-src/host/harness-session-coordinator.mjs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
function agentsFromContext(ctx) {
|
||||
if (typeof ctx?.get !== 'function') return undefined;
|
||||
let agents;
|
||||
try {
|
||||
agents = ctx.get('agents');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (agents === undefined || agents === null) return undefined;
|
||||
if (typeof agents.get !== 'function') {
|
||||
throw new TypeError('dsh-im requires a callable AgentRegistry when ctx.get("agents") is present');
|
||||
}
|
||||
return agents;
|
||||
}
|
||||
|
||||
function currentOwnedTurn(agent, expectedTurn, promptRpcId) {
|
||||
if (agent?.status !== 'running') return false;
|
||||
const events = agent?.session?.events;
|
||||
if (!Array.isArray(events)) return false;
|
||||
|
||||
let openTurn = null;
|
||||
let owned = false;
|
||||
for (const event of events) {
|
||||
if (event?.type === 'turn/start') {
|
||||
openTurn = event.data?.turn ?? null;
|
||||
owned = false;
|
||||
continue;
|
||||
}
|
||||
if (event?.type === 'turn/end' && event.data?.turn === openTurn) {
|
||||
openTurn = null;
|
||||
owned = false;
|
||||
continue;
|
||||
}
|
||||
if (openTurn === expectedTurn
|
||||
&& event?.type === 'user/message'
|
||||
&& event.data?.source?.rpcId === promptRpcId) {
|
||||
owned = true;
|
||||
}
|
||||
}
|
||||
return openTurn === expectedTurn && owned;
|
||||
}
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
|
||||
for (const child of Object.values(value)) deepFreeze(child);
|
||||
return Object.freeze(value);
|
||||
}
|
||||
|
||||
function steeringMessage(text) {
|
||||
return deepFreeze({
|
||||
id: randomUUID(),
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
});
|
||||
}
|
||||
|
||||
function agentBusyError(cause) {
|
||||
const error = new Error('Session is busy with an active turn or maintenance task', { cause });
|
||||
error.code = 'agent-busy';
|
||||
return error;
|
||||
}
|
||||
|
||||
function createControlExecutor(agents) {
|
||||
return ({ sessionId, expectedTurn, promptRpcId, action, text }) => {
|
||||
const agent = agents.get(sessionId);
|
||||
// An unattached Session cannot be coordinated in-process. Let the client
|
||||
// retain its legacy HTTP path for deployments where attachment is lazy.
|
||||
if (!agent) return undefined;
|
||||
if (!currentOwnedTurn(agent, expectedTurn, promptRpcId)) return false;
|
||||
|
||||
if (action === 'stop') {
|
||||
agent.cancel({ kind: 'user' }, { keepInbox: true });
|
||||
return true;
|
||||
}
|
||||
if (action === 'steer') {
|
||||
if (typeof text !== 'string' || !text.trim()) return false;
|
||||
// inject() never wakes an idle driver. Because validation and injection
|
||||
// share one JS tick, this context can only target this live turn's next step.
|
||||
agent.inject(steeringMessage(text));
|
||||
return true;
|
||||
}
|
||||
throw new TypeError(`Unsupported Harness control action: ${String(action)}`);
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionMaintenanceExecutor(agents) {
|
||||
return ({ sessionId, operation }) => {
|
||||
if (typeof operation !== 'function') throw new TypeError('maintenance operation is required');
|
||||
const agent = agents.get(sessionId);
|
||||
if (!agent) return operation();
|
||||
try {
|
||||
// runMaintenance claims the true-idle phase synchronously. A prompt or a
|
||||
// second maintenance operation therefore cannot interleave with the RPC.
|
||||
return agent.runMaintenance((signal) => {
|
||||
if (agents.get(sessionId) !== agent) {
|
||||
const error = new Error('Session agent changed before maintenance started');
|
||||
error.code = 'agent-unavailable';
|
||||
throw error;
|
||||
}
|
||||
return operation(signal);
|
||||
});
|
||||
} catch (error) {
|
||||
throw agentBusyError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build optional same-process Session executors without adding a hard Cordis
|
||||
* service injection. Fixtures and deployments without AgentRegistry preserve
|
||||
* the existing HTTP behavior.
|
||||
*/
|
||||
export function createHarnessSessionExecutors(ctx, provided = {}) {
|
||||
const { controlExecutor, sessionMaintenanceExecutor } = provided;
|
||||
if (controlExecutor !== undefined && typeof controlExecutor !== 'function') {
|
||||
throw new TypeError('controlExecutor must be a function');
|
||||
}
|
||||
if (sessionMaintenanceExecutor !== undefined
|
||||
&& typeof sessionMaintenanceExecutor !== 'function') {
|
||||
throw new TypeError('sessionMaintenanceExecutor must be a function');
|
||||
}
|
||||
|
||||
const agents = controlExecutor && sessionMaintenanceExecutor
|
||||
? undefined
|
||||
: agentsFromContext(ctx);
|
||||
return {
|
||||
controlExecutor: controlExecutor ?? (agents ? createControlExecutor(agents) : undefined),
|
||||
sessionMaintenanceExecutor: sessionMaintenanceExecutor
|
||||
?? (agents ? createSessionMaintenanceExecutor(agents) : undefined),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue