dsh-session-bridge 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tools.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  foldMessages,
23
23
  maxSeq,
24
24
  resolveTargetCwd,
25
+ segmentsSince,
25
26
  sessionEvents,
26
27
  statusSnapshot,
27
28
  titleOf,
@@ -31,7 +32,7 @@ import {
31
32
  workspaceBySession,
32
33
  } from './core.ts'
33
34
  import type { BridgeRegistry } from './registry.ts'
34
- import type { SessionMonitor, MonitorConfig, MonitorEntryState } from './monitor.ts'
35
+ import type { SessionMonitor, MonitorConfig, MonitorEntryState, CoTRule } from './monitor.ts'
35
36
 
36
37
  type SessionIdBrand = { readonly __sessionIdBrand?: never }
37
38
 
@@ -393,17 +394,19 @@ interface WaitArgsTool {
393
394
  sinceSeq?: number
394
395
  timeoutMs?: number
395
396
  requireTurnEnd?: boolean
397
+ waitFor?: 'reply' | 'segment'
396
398
  }
397
399
 
398
400
  function registerWait(env: BridgeEnv): void {
399
401
  env.ctx.tools.register(defineTool({
400
402
  name: 'session_bridge_wait',
401
- description: 'Wait for a session next assistant reply: blocks (polling the session log) until a NEW assistant text reply appears after sinceSeq (default: the latest seq at call time). Returns the reply summary, or timedOut/aborted when the deadline or caller cancellation ends the wait. Use it to consume a reply produced asynchronously by another session (e.g. a session you sent a message to, or one working on its own).',
403
+ description: 'Wait for a session next assistant output: blocks (polling the session log) until a NEW assistant output appears after sinceSeq (default: the latest seq at call time). waitFor=reply returns as soon as a new assistant TEXT reply is readable; waitFor=segment returns as soon as any new COMPLETED output segment appears (an assistant/message step — text, reasoning, or tool-call turn), i.e. it does NOT wait for the whole turn, so you can observe the chain-of-thought/output paragraph by paragraph as it is produced. Returns the output summary, or timedOut/aborted when the deadline or caller cancellation ends the wait. Use it to consume output produced asynchronously by another session (e.g. a session you sent a message to, or one working on its own).',
402
404
  parameters: {
403
405
  sessionId: { type: 'string', required: true, description: 'Session id to wait on.' },
404
406
  sinceSeq: { type: 'number', description: 'Only replies after this event seq count (default: latest seq at call time).' },
405
407
  timeoutMs: { type: 'number', description: 'Wait budget in milliseconds (default 180000, max 3600000); timed out waits return the partial result instead of failing.' },
406
408
  requireTurnEnd: { type: 'boolean', description: 'When true, wait for the reply turn/end to settle before returning (default false; false returns as soon as the reply text is readable).' },
409
+ waitFor: { type: 'string', enum: ['reply', 'segment'], description: 'reply (default) waits for a new assistant TEXT reply; segment waits for any new completed output segment (an assistant/message step, incl. reasoning/tool turns) and returns it immediately, without waiting for the whole turn.' },
407
410
  },
408
411
  output: {
409
412
  schema: { type: 'object', additionalProperties: true },
@@ -425,12 +428,16 @@ function registerWait(env: BridgeEnv): void {
425
428
  if (agent === undefined) {
426
429
  throw new Error('session ' + JSON.stringify(args.sessionId) + ' is not live — call session_bridge_resume first (waiting requires a live session)')
427
430
  }
428
- // 默认 baseline = 当前最后一条带文本 assistant 行的 seq:让 wait 只等待
429
- // 之后新出现的文本回复,避免把"已存在的文本"当成待等回复,同时不被
430
- // 文本后追加的无文本中间块(推理尾块/工具结果)干扰。
431
+ // 默认 baseline = 当前最后一条(带文本的)assistant 行的 seq:让 wait 只等待
432
+ // 之后新出现的输出,避免把"已存在的输出"当成待等内容,同时不被文本后追加的
433
+ // 无文本中间块(推理尾块/工具结果)干扰。segment 模式下以最后一个已完成段落为界。
434
+ const waitSegment = args.waitFor === 'segment'
431
435
  let baseline: number
432
436
  if (typeof args.sinceSeq === 'number' && Number.isInteger(args.sinceSeq) && args.sinceSeq >= 0) {
433
437
  baseline = args.sinceSeq
438
+ } else if (waitSegment) {
439
+ const segs = segmentsSince(sessionEvents(agent.session))
440
+ baseline = segs.length === 0 ? -1 : (segs[segs.length - 1]?.seq ?? -1)
434
441
  } else {
435
442
  let lastText = -1
436
443
  for (const row of foldMessages(sessionEvents(agent.session))) {
@@ -444,6 +451,7 @@ function registerWait(env: BridgeEnv): void {
444
451
  timeoutMs: clampTimeout(args.timeoutMs),
445
452
  signal: exec.signal,
446
453
  requireTurnEnd: args.requireTurnEnd === true,
454
+ ...(waitSegment ? { waitForSegment: true } : {}),
447
455
  })
448
456
  env.registry.touch(args.sessionId)
449
457
  return asJson({
@@ -455,6 +463,77 @@ function registerWait(env: BridgeEnv): void {
455
463
  }))
456
464
  }
457
465
 
466
+ interface SegmentsArgs {
467
+ sessionId: string
468
+ sinceSeq?: number
469
+ limit?: number
470
+ }
471
+ function registerSegments(env: BridgeEnv): void {
472
+ env.ctx.tools.register(defineTool({
473
+ name: 'session_bridge_segments',
474
+ description: 'Read the completed output segments of a session incrementally - each finished assistant step (one assistant/message paragraph), without waiting for the whole turn. Pass sinceSeq to page forward. Use this to watch a session produce its chain-of-thought / output paragraph by paragraph as each step finishes.',
475
+ parameters: {
476
+ sessionId: { type: 'string', required: true, description: 'Session id to read segments from (live or offline).' },
477
+ sinceSeq: { type: 'number', description: 'Only return completed segments after this event seq (paging cursor).' },
478
+ limit: { type: 'number', description: 'Maximum number of segments to return (default 10, max 50).' },
479
+ },
480
+ output: {
481
+ schema: { type: 'object', additionalProperties: true },
482
+ render: (_args, value) => {
483
+ const v = value as Record<string, unknown>
484
+ const segs = (v.segments as Array<Record<string, unknown>> | null) ?? []
485
+ if (segs.length === 0) return [{ type: 'text' as const, text: '(no completed segments)' }]
486
+ const lines = segs.map((sg) => {
487
+ const head = 'seg #' + String(sg.seq) + ' (turn ' + String(sg.turn) + ' step ' + String(sg.step) + ')'
488
+ const text = typeof sg.text === 'string' ? sg.text : ''
489
+ const reason = typeof sg.reasoning === 'string' && sg.reasoning !== '' ? ' [reasoning ' + sg.reasoning.length + ' chars]' : ''
490
+ const tools = Array.isArray(sg.toolCalls) && sg.toolCalls.length > 0 ? ' [tools: ' + sg.toolCalls.join(',') + ']' : ''
491
+ return head + tools + reason + ': ' + text.slice(0, 160)
492
+ })
493
+ return [{ type: 'text' as const, text: lines.join('\n') }]
494
+ },
495
+ },
496
+ async execute(args: SegmentsArgs) {
497
+ const agent = liveAgent(env, args.sessionId)
498
+ let events: readonly SessionEvent[]
499
+ let live: boolean
500
+ if (agent !== undefined) {
501
+ events = sessionEvents(agent.session)
502
+ live = true
503
+ } else {
504
+ try {
505
+ const persistence = env.ctx.sessionPersistence as unknown as { inspect(id: string): Promise<{ events: readonly SessionEvent[] }> }
506
+ const inspection = await persistence.inspect(args.sessionId)
507
+ events = inspection.events
508
+ live = false
509
+ } catch (error) {
510
+ throw new Error(String(error))
511
+ }
512
+ }
513
+ const sinceSeq = typeof args.sinceSeq === 'number' && Number.isInteger(args.sinceSeq) && args.sinceSeq >= 0 ? args.sinceSeq : 0
514
+ const limit = clampLimit(args.limit, 10, 50)
515
+ const segs = segmentsSince(events, sinceSeq)
516
+ env.registry.touch(args.sessionId)
517
+ const nextCursorSeq = segs.length === 0 ? sinceSeq : (segs[segs.length - 1]?.seq ?? sinceSeq)
518
+ return asJson({
519
+ sessionId: args.sessionId,
520
+ live,
521
+ nextCursorSeq,
522
+ segments: segs.map((sg) => ({
523
+ seq: sg.seq,
524
+ time: sg.time,
525
+ turn: sg.turn,
526
+ step: sg.step,
527
+ openTurn: sg.openTurn,
528
+ ...(sg.text === undefined ? {} : { text: sg.text }),
529
+ ...(sg.reasoning === undefined ? {} : { reasoning: sg.reasoning }),
530
+ ...(sg.toolCalls.length > 0 ? { toolCalls: sg.toolCalls } : {}),
531
+ })),
532
+ })
533
+ },
534
+ }))
535
+ }
536
+
458
537
  interface ReadArgs {
459
538
  sessionId: string
460
539
  sinceSeq?: number
@@ -699,6 +778,7 @@ export function registerBridgeTools(env: BridgeEnv): void {
699
778
  registerSend(env)
700
779
  registerResume(env)
701
780
  registerWait(env)
781
+ registerSegments(env)
702
782
  registerRead(env)
703
783
  registerFind(env)
704
784
  registerStatus(env)
@@ -717,6 +797,25 @@ interface MonitorStartArgs {
717
797
  onStallSteer?: string
718
798
  onOffTrackSteer?: string
719
799
  label?: string
800
+ coRules?: Array<{ match?: string; field?: string; value?: string; action?: string; message?: string }>
801
+ cotMinHits?: number
802
+ }
803
+
804
+ /** 校验并规范化 coRules 参数为 CoTRule[];非法项抛清晰错误。 */
805
+ function parseCoRules(raw: Array<{ match?: string; field?: string; value?: string; action?: string; message?: string }>): CoTRule[] {
806
+ return raw.map((rule, index) => {
807
+ const match = rule.match === 'contains' || rule.match === 'not-contains' ? rule.match : (() => { throw new Error('coRules[' + index + '].match must be "contains" or "not-contains"') })()
808
+ const field = rule.field === 'reasoning' || rule.field === 'text' || rule.field === 'both' ? rule.field : (() => { throw new Error('coRules[' + index + '].field must be "reasoning", "text" or "both"') })()
809
+ const action = rule.action === 'steer' || rule.action === 'cancel' ? rule.action : (() => { throw new Error('coRules[' + index + '].action must be "steer" or "cancel"') })()
810
+ const value = typeof rule.value === 'string' && rule.value.trim() !== '' ? rule.value.trim() : (() => { throw new Error('coRules[' + index + '].value must be a non-empty string') })()
811
+ return {
812
+ match,
813
+ field,
814
+ action,
815
+ value,
816
+ ...(typeof rule.message === 'string' && rule.message.trim() !== '' ? { message: rule.message.trim() } : {}),
817
+ }
818
+ })
720
819
  }
721
820
 
722
821
  function renderMonitorState(entry: MonitorEntryState): string[] {
@@ -724,6 +823,8 @@ function renderMonitorState(entry: MonitorEntryState): string[] {
724
823
  lines.push(`monitoring ${entry.config.sessionId}${entry.config.label === undefined ? '' : ' ("' + entry.config.label + '")'}`)
725
824
  lines.push(`interval: ${entry.config.intervalMs}ms | stalled: ${String(entry.config.stalledMs ?? 60000)}ms | maxStuck: ${String(entry.config.maxStuckCycles ?? 3)}`)
726
825
  lines.push(`cycles: ${entry.cycles} | stuck: ${entry.stuckCount} | lastAction: ${entry.lastAction}`)
826
+ const cotCount = (entry.config.coTRules ?? []).length
827
+ if (cotCount > 0) lines.push(`coRules: ${cotCount} rule(s) (minHits ${String(entry.config.cotMinHits ?? 1)})`)
727
828
  if (entry.lastNote !== '') lines.push(`note: ${entry.lastNote}`)
728
829
  if (entry.done) lines.push('done: yes')
729
830
  return lines
@@ -732,7 +833,7 @@ function renderMonitorState(entry: MonitorEntryState): string[] {
732
833
  function registerMonitor(env: BridgeEnv): void {
733
834
  env.ctx.tools.register(defineTool({
734
835
  name: 'session_bridge_monitor_start',
735
- description: 'Start a background watchdog on a main session: poll its progress at an interval, and automatically schedule — steer the session when it stalls (or, with useLlm, when it drifts off-track), cancel it when it stays stuck past maxStuckCycles, and stop when a done keyword appears while idle. This is the "monitor worker" that watches a main task thread and corrects/stops it. Uses session_bridge_status-style facts; pass sessionId of a live session. Returns the watchdog state.',
836
+ description: 'Start a background watchdog on a main session: poll its progress at an interval, and automatically schedule — steer the session when it stalls (or, with useLlm, when it drifts off-track), cancel it when it stays stuck past maxStuckCycles, and stop when a done keyword appears while idle. It can also enforce chain-of-thought rules via coRules: e.g. coRules=[{match:"not-contains",field:"reasoning",value:"I\'m",action:"cancel"}] stops the session the moment its live reasoning stops containing I\'m (evaluated on each poll while running). This is the "monitor worker" that watches a main task thread and corrects/stops it. Uses session_bridge_status-style facts; pass sessionId of a live session. Returns the watchdog state.',
736
837
  parameters: {
737
838
  sessionId: { type: 'string', required: true, description: 'Target main session id to watch (must be live).' },
738
839
  intervalMs: { type: 'number', description: 'Poll interval in ms (default 10000, min 5000).' },
@@ -743,6 +844,14 @@ function registerMonitor(env: BridgeEnv): void {
743
844
  onStallSteer: { type: 'string', description: 'Steer text injected on a stall/nudge (default: ask to summarize progress and continue).' },
744
845
  onOffTrackSteer: { type: 'string', description: 'Steer text injected when LLM judges the task off-track (default: ask to return to the original goal).' },
745
846
  label: { type: 'string', description: 'Optional human label for logs/display.' },
847
+ coRules: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
848
+ match: { type: 'string', required: true, enum: ['contains', 'not-contains'], description: 'contains = must include value in the chosen field; not-contains = must NOT include it.' },
849
+ field: { type: 'string', required: true, enum: ['reasoning', 'text', 'both'], description: 'Match on reasoning (chain-of-thought), text (reply), or both (either).' },
850
+ value: { type: 'string', required: true, description: 'The substring to match (non-empty).' },
851
+ action: { type: 'string', required: true, enum: ['steer', 'cancel'], description: 'steer injects a guiding message; cancel terminates the session.' },
852
+ message: { type: 'string', description: 'Custom steer text (default is a guidance prompt).' },
853
+ } }, description: 'Chain-of-thought rules: when a rule stays matched for cotMinHits consecutive polls (default 1), trigger its action. Example: [{match:"not-contains",field:"reasoning",value:"I\'m",action:"cancel"}] stops the session the moment its live reasoning no longer contains I\'m.' },
854
+ cotMinHits: { type: 'number', description: 'How many consecutive matched polls before a CoT rule fires (default 1 = immediately).' },
746
855
  },
747
856
  output: {
748
857
  schema: { type: 'object', additionalProperties: true },
@@ -765,6 +874,8 @@ function registerMonitor(env: BridgeEnv): void {
765
874
  ...(typeof args.onStallSteer === 'string' && args.onStallSteer.trim() !== '' ? { onStallSteer: args.onStallSteer.trim() } : {}),
766
875
  ...(typeof args.onOffTrackSteer === 'string' && args.onOffTrackSteer.trim() !== '' ? { onOffTrackSteer: args.onOffTrackSteer.trim() } : {}),
767
876
  ...(typeof args.label === 'string' && args.label.trim() !== '' ? { label: args.label.trim() } : {}),
877
+ ...(Array.isArray(args.coRules) && args.coRules.length > 0 ? { coTRules: parseCoRules(args.coRules) } : {}),
878
+ ...(typeof args.cotMinHits === 'number' && Number.isInteger(args.cotMinHits) && args.cotMinHits >= 1 ? { cotMinHits: Math.floor(args.cotMinHits) } : {}),
768
879
  }
769
880
  const entry = env.monitor.start(config)
770
881
  env.registry.touch(args.sessionId)
@@ -812,13 +923,32 @@ function registerMonitor(env: BridgeEnv): void {
812
923
  }))
813
924
  }
814
925
 
926
+ type StatusReasoning = 'none' | 'last' | 'live' | 'tail'
927
+
815
928
  interface StatusArgs {
816
929
  sessionId: string
817
930
  stalledMsThreshold?: number
818
931
  recent?: number
932
+ reasoning?: StatusReasoning
819
933
  }
820
934
 
821
- /** 渲染监控快照为一行摘要:运行态 + openTurn + 卡住/待处理 + 最新回复。 */
935
+ /** reasoning 选项裁剪快照里的思维链字段(避免入 token 时有 diff 语义差)。 */
936
+ function pruneReasoning(snapshot: BridgeStatusSnapshot, mode: StatusReasoning): BridgeStatusSnapshot {
937
+ if (mode === 'tail') return snapshot
938
+ const rest: BridgeStatusSnapshot = { ...snapshot }
939
+ delete (rest as { reasoningTail?: unknown }).reasoningTail
940
+ if (mode === 'none') {
941
+ delete (rest as { lastReasoning?: unknown }).lastReasoning
942
+ delete (rest as { liveReasoning?: unknown }).liveReasoning
943
+ } else if (mode === 'last') {
944
+ delete (rest as { liveReasoning?: unknown }).liveReasoning
945
+ } else { // 'live'
946
+ delete (rest as { lastReasoning?: unknown }).lastReasoning
947
+ }
948
+ return rest
949
+ }
950
+
951
+ /** 渲染监控快照为一行摘要:运行态 + openTurn + 卡住/待处理 + 最新回复(+ 思维链预览)。 */
822
952
  function renderStatus(snapshot: BridgeStatusSnapshot, stalledMsThreshold: number): string[] {
823
953
  const lines: string[] = []
824
954
  const runLabel = snapshot.running === 'running' ? 'running' : 'idle'
@@ -831,17 +961,22 @@ function renderStatus(snapshot: BridgeStatusSnapshot, stalledMsThreshold: number
831
961
  }
832
962
  if (snapshot.pendingWork) lines.push(`pendingWork: ${snapshot.nextTurnCount} turn + ${snapshot.nextStepCount} step`)
833
963
  if (snapshot.lastAssistantText !== undefined) lines.push(`lastReply: ${snapshot.lastAssistantText}`)
964
+ if (snapshot.reasoningTail !== undefined && snapshot.reasoningTail !== '') {
965
+ const preview = snapshot.reasoningTail.length > 160 ? snapshot.reasoningTail.slice(0, 160) + '…' : snapshot.reasoningTail
966
+ lines.push(`reasoning: ${preview}`)
967
+ }
834
968
  return lines
835
969
  }
836
970
 
837
971
  function registerStatus(env: BridgeEnv): void {
838
972
  env.ctx.tools.register(defineTool({
839
973
  name: 'session_bridge_status',
840
- description: 'Inspect a session\'s live progress for monitoring/scheduling. Returns running/idle, whether a turn is open, last turn number, time since the last event (for stall detection), pending queued work, and the latest text reply. When stalledMsThreshold is given, marks the session as stalled when the time since the last event exceeds it. Pass sessionId of a live session (use session_bridge_find to locate; session_bridge_resume to bring an offline one online). Use this as the "observe" step of a monitor→decide→steer/cancel loop.',
974
+ description: 'Inspect a session\'s live progress for monitoring/scheduling. Returns running/idle, whether a turn is open, last turn number, time since the last event (for stall detection), pending queued work, and the latest text reply. It also surfaces the session\'s chain-of-thought: lastReasoning is the most recent finalized reasoning block, liveReasoning is the in-flight reasoning streamed for the current handled turn (reasoning-delta), and reasoningTail is a compact merged preview. reasoning=none drops all three to keep tokens small. When stalledMsThreshold is given, marks the session as stalled when the time since the last event exceeds it. Pass sessionId of a live session (use session_bridge_find to locate; session_bridge_resume to bring an offline one online). Use this as the "observe" step of a monitor→decide→steer/cancel loop.',
841
975
  parameters: {
842
976
  sessionId: { type: 'string', required: true, description: 'Session id to inspect (must be live).' },
843
977
  stalledMsThreshold: { type: 'number', description: 'Mark the session STALLED when time since the last event exceeds this many ms (default 60000).' },
844
978
  recent: { type: 'number', description: 'Number of recent messages to include in the snapshot (default 8, max 20).' },
979
+ reasoning: { type: 'string', enum: ['none', 'last', 'live', 'tail'], description: 'Which chain-of-thought fields to include: tail (default) returns lastReasoning/liveReasoning/reasoningTail; last only the finalized reasoning; live only the in-flight reasoning; none drops all reasoning fields.' },
845
980
  },
846
981
  output: {
847
982
  schema: { type: 'object', additionalProperties: true },
@@ -860,7 +995,8 @@ function registerStatus(env: BridgeEnv): void {
860
995
  throw new Error('session ' + JSON.stringify(args.sessionId) + ' is not live — call session_bridge_resume first (status requires a live session)')
861
996
  }
862
997
  const threshold = typeof args.stalledMsThreshold === 'number' && Number.isFinite(args.stalledMsThreshold) && args.stalledMsThreshold >= 0 ? args.stalledMsThreshold : 60000
863
- const snapshot = statusSnapshot(env.ctx, agent)
998
+ const reasoningMode: StatusReasoning = args.reasoning === 'none' || args.reasoning === 'last' || args.reasoning === 'live' ? args.reasoning : 'tail'
999
+ const snapshot = pruneReasoning(statusSnapshot(env.ctx, agent), reasoningMode)
864
1000
  const shown = typeof args.recent === 'number' && Number.isInteger(args.recent) && args.recent >= 0 ? Math.min(args.recent, 20) : 8
865
1001
  const envCwd = agent.session.header.cwd
866
1002
  env.registry.touch(args.sessionId, {