dsh-task-time-cost 0.2.8-beta.2 → 0.2.9-beta.1
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/CHANGELOG.md +9 -0
- package/COMPATIBILITY.md +2 -2
- package/README.en.md +7 -6
- package/README.md +7 -6
- package/docs/public/guide.en.md +4 -2
- package/docs/public/guide.zh.md +4 -2
- package/lib/client.js +23 -20
- package/lib/index.js +35 -11
- package/lib/index.js.map +2 -2
- package/package.json +1 -1
- package/src/client/details.tsx +1 -1
- package/src/client/format.ts +19 -0
- package/src/client/index.tsx +21 -37
- package/src/client/locale.ts +6 -0
- package/src/client/styles.ts +4 -1
- package/src/host/engine.ts +17 -4
- package/src/host/index.ts +2 -2
- package/src/shared/wire.ts +1 -1
- package/src/shared/workload.ts +19 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-task-time-cost",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9-beta.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remaining-time, Token budget and reference cost estimates for DeepSeek Harness, calibrated locally without a separate model call.",
|
|
6
6
|
"license": "MIT",
|
package/src/client/details.tsx
CHANGED
|
@@ -9,7 +9,7 @@ function Facts({ rows }: { rows: ReadonlyArray<readonly [string, React.ReactNode
|
|
|
9
9
|
|
|
10
10
|
const reasonKeys: Record<string, MessageKey> = {
|
|
11
11
|
'engineering-assumption': 'reasonHeuristic', 'engineering-workload-template': 'reasonWorkloadTemplate',
|
|
12
|
-
'local-workload-ewma': 'reasonLocalWorkload', 'public-endpoint-reference': 'reasonPublicEndpoint',
|
|
12
|
+
'local-workload-ewma': 'reasonLocalWorkload', 'local-workload-distribution': 'reasonWorkloadDistribution', 'public-endpoint-reference': 'reasonPublicEndpoint',
|
|
13
13
|
'reference-range-not-calibrated-confidence': 'reasonReferenceRange', 'conditional-samples-insufficient': 'reasonFewSamples',
|
|
14
14
|
'reference-range-exhausted-reassessing': 'reasonReassessing', 'output-budget-exhausted-task-still-running': 'reasonBudgetExhausted',
|
|
15
15
|
'waiting-for-model-response': 'reasonWaitingModel', 'mixed-route-upstream-unknown': 'reasonMixedRoute',
|
package/src/client/format.ts
CHANGED
|
@@ -73,6 +73,11 @@ function formatCoarse(ms: number, t: Translate): string {
|
|
|
73
73
|
return rest === 0 ? t('durationHoursOnly', { value: hours }) : t('durationHours', { value: hours, minutes: rest });
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/** Wide early references should not masquerade as a useful countdown. */
|
|
77
|
+
export function isWideEta(range: Span | null | undefined): boolean {
|
|
78
|
+
return validSpan(range) && range.high - range.low >= 300_000 && range.high >= Math.max(60_000, range.low) * 4;
|
|
79
|
+
}
|
|
80
|
+
|
|
76
81
|
/** Compact ETA for the one-line display: coarse bounds, latin units, stable width. */
|
|
77
82
|
export function formatEta(range: Span | null | undefined, t: Translate): string {
|
|
78
83
|
if (!validSpan(range)) return t('unknown');
|
|
@@ -83,6 +88,20 @@ export function formatEta(range: Span | null | undefined, t: Translate): string
|
|
|
83
88
|
return low === high ? low : t('range', { low, high });
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
/** Compact counts retain separate observed and forecast labels in the summary. */
|
|
92
|
+
export function compactTokens(value: number | null | undefined, t: Translate): string {
|
|
93
|
+
if (value == null || !Number.isFinite(value) || value < 0) return t('unknown');
|
|
94
|
+
if (value < 1_000) return String(Math.round(value));
|
|
95
|
+
const divisor = value < 1_000_000 ? 1_000 : 1_000_000;
|
|
96
|
+
const scaled = value / divisor;
|
|
97
|
+
return `${scaled >= 100 ? Math.round(scaled) : Number(scaled.toFixed(1))}${divisor === 1_000 ? 'k' : 'M'}`;
|
|
98
|
+
}
|
|
99
|
+
export function compactTokenRange(range: Span | null | undefined, t: Translate): string {
|
|
100
|
+
if (!validSpan(range)) return t('unknown');
|
|
101
|
+
const low = compactTokens(range.low, t), high = compactTokens(range.high, t);
|
|
102
|
+
return low === high ? low : t('range', { low, high });
|
|
103
|
+
}
|
|
104
|
+
|
|
86
105
|
export function formatTokenRange(range: Span | null | undefined, locale: string, t: Translate): string {
|
|
87
106
|
if (!validSpan(range)) return t('unknown');
|
|
88
107
|
const low = formatNumber(range.low, locale, t);
|
package/src/client/index.tsx
CHANGED
|
@@ -2,9 +2,9 @@ import * as React from 'react';
|
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
3
|
import type { Context } from '@deepseek-ai/cordis';
|
|
4
4
|
import type { ChatSnapshot, TurnTailChatData } from '@deepseek-ai/dsh-client-ui-chat/client';
|
|
5
|
-
import type {
|
|
5
|
+
import type { EstimatorRemote } from '../shared/wire.ts';
|
|
6
6
|
import { EstimateDetails } from './details.tsx';
|
|
7
|
-
import { costSummary, costUnconfigured, formatDuration, formatEta, isTerminal, localTiming } from './format.ts';
|
|
7
|
+
import { compactTokens, compactTokenRange, costSummary, costUnconfigured, formatDuration, formatEta, isTerminal, isWideEta, localTiming } from './format.ts';
|
|
8
8
|
import { en, zh, LOCALE_NAMESPACE } from './locale.ts';
|
|
9
9
|
import type { Translate } from './locale.ts';
|
|
10
10
|
import { Management } from './management.tsx';
|
|
@@ -43,33 +43,6 @@ function readMarker(value: unknown): ProjectionMarker | null {
|
|
|
43
43
|
}
|
|
44
44
|
const idleSubscribe = () => () => {};
|
|
45
45
|
|
|
46
|
-
/** Compact human token count, matching the host's own usage meta. */
|
|
47
|
-
function compactTokens(value: number | null | undefined): string | null {
|
|
48
|
-
if (value == null || !Number.isFinite(value) || value < 0) return null;
|
|
49
|
-
if (value < 1_000) return String(Math.round(value));
|
|
50
|
-
if (value < 1_000_000) {
|
|
51
|
-
const scaled = value / 1_000;
|
|
52
|
-
return `${scaled >= 100 ? Math.round(scaled) : Number(scaled.toFixed(1))}k`;
|
|
53
|
-
}
|
|
54
|
-
const scaled = value / 1_000_000;
|
|
55
|
-
return `${scaled >= 100 ? Math.round(scaled) : Number(scaled.toFixed(1))}M`;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* The bare count, for places whose own label already says "tokens": a row
|
|
60
|
-
* reading `Observed tokens 46.2M tokens` repeats the unit the label supplied.
|
|
61
|
-
*/
|
|
62
|
-
function tokenValue(snapshot: EstimateSnapshot | null, _locale: string, t: Translate): string {
|
|
63
|
-
if (!snapshot) return t('unknown');
|
|
64
|
-
const observed = compactTokens(snapshot.observedTokens);
|
|
65
|
-
if (observed) return observed;
|
|
66
|
-
const estimate = snapshot.estimatedTotalTokens;
|
|
67
|
-
if (!estimate) return t('unknown');
|
|
68
|
-
const low = compactTokens(estimate.low) ?? t('unknown');
|
|
69
|
-
const high = compactTokens(estimate.high) ?? t('unknown');
|
|
70
|
-
return low === high ? low : t('range', { low, high });
|
|
71
|
-
}
|
|
72
|
-
|
|
73
46
|
/**
|
|
74
47
|
* Small clock that carries the task state by colour instead of by text: the
|
|
75
48
|
* line itself must stay a single short ETA, so a dot plus a word would repeat
|
|
@@ -118,7 +91,7 @@ export function applyClient(ctx: Context): void {
|
|
|
118
91
|
const frozenNow = React.useCallback(() => state.receivedAt, [state.receivedAt]);
|
|
119
92
|
const now = React.useSyncExternalStore(ticking ? store.clock.subscribe : idleSubscribe, ticking ? store.clock.getSnapshot : frozenNow, frozenNow);
|
|
120
93
|
const [expanded, setExpanded] = React.useState(false);
|
|
121
|
-
const [view, setView] = React.useState<'summary' | 'details' | 'settings'>('summary');
|
|
94
|
+
const [view, setView] = React.useState<'summary' | 'more' | 'details' | 'settings'>('summary');
|
|
122
95
|
const summary = React.useRef<HTMLButtonElement>(null);
|
|
123
96
|
const panel = React.useRef<HTMLDivElement>(null);
|
|
124
97
|
const firstAction = React.useRef<HTMLButtonElement>(null);
|
|
@@ -140,12 +113,14 @@ export function applyClient(ctx: Context): void {
|
|
|
140
113
|
if (event.key !== 'Escape') return;
|
|
141
114
|
event.preventDefault();
|
|
142
115
|
setExpanded(false);
|
|
116
|
+
setView('summary');
|
|
143
117
|
summary.current?.focus();
|
|
144
118
|
};
|
|
145
119
|
const onOutside = (event: Event) => {
|
|
146
120
|
const target = event.target as Node | null;
|
|
147
121
|
if (!target || summary.current?.contains(target) || panel.current?.contains(target)) return;
|
|
148
122
|
setExpanded(false);
|
|
123
|
+
setView('summary');
|
|
149
124
|
};
|
|
150
125
|
document.addEventListener('keydown', onKeyDown);
|
|
151
126
|
document.addEventListener('pointerdown', onOutside, true);
|
|
@@ -162,16 +137,18 @@ export function applyClient(ctx: Context): void {
|
|
|
162
137
|
const elapsed = formatDuration(timing?.activeMs ?? snapshot?.elapsedActiveMs, t);
|
|
163
138
|
const retained = !tail && sessionRemaining === null && state.lastForecast
|
|
164
139
|
&& (!snapshot || state.lastForecast.taskKey === snapshot.taskKey) && (active || !snapshot && targetTurn !== null) ? state.lastForecast : null;
|
|
165
|
-
const
|
|
140
|
+
const displayedRemaining = sessionRemaining ?? retained?.remainingMs;
|
|
141
|
+
const wide = isWideEta(displayedRemaining);
|
|
142
|
+
const eta = formatEta(displayedRemaining, t);
|
|
166
143
|
// Keep the last time reference while the new plan is unavailable. It is
|
|
167
144
|
// frozen and labelled in the dialog, never used as a current cost budget.
|
|
168
|
-
const line = tail ? t('doneElapsed', { elapsed }) : (active && sessionRemaining !== null || retained) ? t('etaEstimate', { value: eta }) : t('titleShort');
|
|
145
|
+
const line = tail ? t('doneElapsed', { elapsed }) : (active && sessionRemaining !== null || retained) ? wide ? t('etaUncertain') : t('etaEstimate', { value: eta }) : t('titleShort');
|
|
169
146
|
const unconfigured = !!snapshot && costUnconfigured(snapshot);
|
|
170
147
|
const referencePrice = snapshot?.reasons.includes('public-reference-price-not-provider-bill') ?? false;
|
|
171
148
|
return <div className="dte" data-slot={tail ? 'assistant-actions' : 'composer-dock'}>
|
|
172
149
|
<style>{styles}</style>
|
|
173
150
|
<button type="button" className={tail ? 'dte-line dte-line-tail' : 'dte-line'} aria-haspopup="dialog" aria-expanded={expanded} aria-controls={`${id}-panel`}
|
|
174
|
-
aria-label={line} onClick={() => setExpanded((value) => !value)} ref={summary}
|
|
151
|
+
aria-label={line} onClick={() => { setView('summary'); setExpanded((value) => !value); }} ref={summary}
|
|
175
152
|
title={terminal ? line : snapshot || retained ? t('etaFull', { value: eta }) : t('title')}>
|
|
176
153
|
<ClockIcon state={snapshot?.state ?? 'idle'} />
|
|
177
154
|
<span className="dte-line-text">{line}</span>
|
|
@@ -182,22 +159,29 @@ export function applyClient(ctx: Context): void {
|
|
|
182
159
|
<div className="dte-pop-head">
|
|
183
160
|
<span className="dte-pop-title"><ClockIcon state={snapshot?.state ?? 'idle'} />{t(tail ? 'turnTimeTitle' : 'titleShort')}</span>
|
|
184
161
|
<span className="dte-pop-value dte-tabular">{terminal ? elapsed : eta}</span>
|
|
162
|
+
<button type="button" ref={firstAction} className="dte-more" aria-label={t('more')} title={t('more')} aria-expanded={view !== 'summary'} aria-controls={`${id}-more`}
|
|
163
|
+
onClick={() => setView(view === 'summary' ? 'more' : 'summary')}>
|
|
164
|
+
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><circle cx="3" cy="8" r="1.2" fill="currentColor" /><circle cx="8" cy="8" r="1.2" fill="currentColor" /><circle cx="13" cy="8" r="1.2" fill="currentColor" /></svg>
|
|
165
|
+
</button>
|
|
185
166
|
</div>
|
|
186
167
|
<div className="dte-pop-rule" aria-hidden="true" />
|
|
187
168
|
<dl className="dte-summary">
|
|
188
169
|
<dt>{t('activeTime')}</dt><dd>{snapshot ? elapsed : t('unknown')}</dd>
|
|
189
170
|
<dt>{t('estimatedTotal')}</dt><dd>{formatEta(snapshot?.estimatedTotalMs ?? retained?.estimatedTotalMs, t)}</dd>
|
|
190
|
-
<dt>{t('tokenTotal')}</dt><dd>{
|
|
171
|
+
<dt>{t('tokenTotal')}</dt><dd>{snapshot?.observedTokenStatus === 'partial' ? t('partialKnown', { value: compactTokens(snapshot.observedTokens, t) }) : compactTokens(snapshot?.observedTokens, t)}</dd>
|
|
172
|
+
{!terminal && <><dt>{t('tokenRemainingOutput')}</dt><dd title={t('tokenRemainingNote')}>{compactTokenRange(snapshot?.estimatedRemainingOutputTokens, t)}</dd>
|
|
173
|
+
{snapshot?.estimatedTotalTokens != null && <><dt>{t('tokenForecast')}</dt><dd>{compactTokenRange(snapshot.estimatedTotalTokens, t)}</dd></>}</>}
|
|
191
174
|
<dt>{t(referencePrice ? 'referenceCost' : 'knownCost')}</dt><dd>{unconfigured ? t('unpriced') : snapshot ? costSummary(snapshot, localeSnapshot.active, t) : t('unknown')}</dd>
|
|
192
175
|
</dl>
|
|
176
|
+
{wide && !terminal && <p className="dte-muted dte-small">{t('wideEtaHint')}</p>}
|
|
193
177
|
{retained && <p className="dte-muted dte-small">{t('retainedForecastHint', { time: new Date(retained.updatedAt).toLocaleString(localeSnapshot.active) })}</p>}
|
|
194
178
|
{referencePrice && <p className="dte-muted dte-small">{t('referencePriceHint')}</p>}
|
|
195
179
|
{unconfigured && <p className="dte-muted dte-small">{t('unpricedHint')}</p>}
|
|
196
180
|
{!unconfigured && snapshot && (snapshot.observedCostStatus === 'partial' || snapshot.forecastCostStatus === 'partial') && <p className="dte-muted dte-small">{t('incompleteCostDetail')}</p>}
|
|
197
181
|
{state.error && <p className="dte-error dte-small" role="alert">{errorText(state.error, t)}</p>}
|
|
198
|
-
<div className="dte-pop-actions">
|
|
199
|
-
<button type="button"
|
|
200
|
-
<button type="button" className={view === 'settings' ? 'dte-link dte-link-active' : 'dte-link'} aria-pressed={view === 'settings'} onClick={() => setView(view === 'settings' ? '
|
|
182
|
+
<div id={`${id}-more`} className="dte-pop-actions" hidden={view === 'summary'}>
|
|
183
|
+
<button type="button" className={view === 'details' ? 'dte-link dte-link-active' : 'dte-link'} aria-pressed={view === 'details'} onClick={() => setView(view === 'details' ? 'more' : 'details')}>{t('details')}</button>
|
|
184
|
+
<button type="button" className={view === 'settings' ? 'dte-link dte-link-active' : 'dte-link'} aria-pressed={view === 'settings'} onClick={() => setView(view === 'settings' ? 'more' : 'settings')}>{t('settings')}</button>
|
|
201
185
|
<span className="dte-spacer" />
|
|
202
186
|
{view === 'details' && <button type="button" className="dte-link" disabled={state.pending} onClick={refresh}>{t('refresh')}</button>}
|
|
203
187
|
</div>
|
package/src/client/locale.ts
CHANGED
|
@@ -9,6 +9,9 @@ export const en = {
|
|
|
9
9
|
tokenRemainingNote: 'Visible output and tool arguments only; excludes hidden reasoning and repeated input.',
|
|
10
10
|
reassessingShort: 'Reassessing',
|
|
11
11
|
upTo: '≤{value}',
|
|
12
|
+
more: 'More estimate options',
|
|
13
|
+
etaUncertain: 'Refining estimate',
|
|
14
|
+
wideEtaHint: 'The task size is still uncertain. A work plan or comparable completed tasks will help narrow this range.',
|
|
12
15
|
details: 'Estimate details',
|
|
13
16
|
settings: 'Settings and calibration',
|
|
14
17
|
close: 'Close',
|
|
@@ -208,6 +211,7 @@ export const en = {
|
|
|
208
211
|
overdue: 'Estimate is being revised',
|
|
209
212
|
reasonHeuristic: 'Uses engineering assumptions until local measurements are available.',
|
|
210
213
|
reasonWorkloadTemplate: 'Workload comes from an engineering template.',
|
|
214
|
+
reasonWorkloadDistribution: 'Workload uses the central 20–80% of comparable completed local tasks, rather than retaining the template spread.',
|
|
211
215
|
reasonLocalWorkload: 'Workload is smoothed from local completed tasks.',
|
|
212
216
|
reasonPublicEndpoint: 'Public endpoint measurements are a reference for this route.',
|
|
213
217
|
reasonReferenceRange: 'This is a reference range, not a calibrated confidence interval.',
|
|
@@ -259,6 +263,7 @@ export const zh: Record<MessageKey, string> = {
|
|
|
259
263
|
title: '任务耗时与费用预估', titleShort: '耗时预估', turnTimeTitle: '本轮用时',
|
|
260
264
|
retainedForecastHint: '正在更新,暂用 {time} 的预估参考。',
|
|
261
265
|
tokenRemainingOutput: '预计剩余输出 Token', tokenRemainingNote: '包含可见输出和工具参数,不含隐藏推理与重复输入。',
|
|
266
|
+
more: '更多预估选项', etaUncertain: '预估待收敛', wideEtaHint: '任务规模尚不确定;工作计划或可比的已完成任务可帮助收窄此区间。',
|
|
262
267
|
reassessingShort: '重新评估', upTo: '≤{value}', details: '预估详情', settings: '设置与校准', close: '关闭',
|
|
263
268
|
refresh: '刷新', retry: '重试', loading: '正在加载…', updating: '正在更新…',
|
|
264
269
|
unavailable: '暂时无法预估', noTask: '任务开始后显示预估。', noSnapshot: '本回合暂无预估。',
|
|
@@ -329,6 +334,7 @@ export const zh: Record<MessageKey, string> = {
|
|
|
329
334
|
rawDetail: 'Host 详情:{message}', sourceDetail: '来源:{value}', unknownRemaining: '剩余时间未知',
|
|
330
335
|
unknownTime: '暂时无法预估耗时', overdue: '正在修正预估',
|
|
331
336
|
reasonHeuristic: '本地实测不足时使用工程假设。', reasonWorkloadTemplate: '工作量来自工程模板。',
|
|
337
|
+
reasonWorkloadDistribution: '工作量采用可比已完成本地任务的 20%–80% 实际分布,不再沿用模板宽幅。',
|
|
332
338
|
reasonLocalWorkload: '工作量由本地已完成任务平滑更新。', reasonPublicEndpoint: '公开端点实测仅作为当前线路的参考。',
|
|
333
339
|
reasonReferenceRange: '这是参考区间,并非经过校准的置信区间。', reasonFewSamples: '相似样本不足,无法给出条件剩余时间。',
|
|
334
340
|
reasonAiPlan: '执行 AI 评估了剩余工作量,再结合当前路由性能换算耗时;这是参考预估,不是已测得的完成时间。',
|
package/src/client/styles.ts
CHANGED
|
@@ -76,7 +76,10 @@ export const styles = `
|
|
|
76
76
|
body portal with the native gap/inset and a bounded internal scroll area. */
|
|
77
77
|
.dte .dte-pop{position:fixed;inset:auto;z-index:1100;box-sizing:border-box;width:max-content;min-width:min(300px,calc(100vw - 24px));max-width:min(440px,calc(100vw - 24px));padding:16px;border:0;border-radius:var(--dsw-radius-lg);background:var(--dsw-specific-menu);backdrop-filter:var(--dsw-menu-backdrop-filter);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent);font-family:inherit;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);cursor:default;max-height:calc(100dvh - 12px - max(12px,var(--dsh-frame-overlay-top,12px)));overflow-x:hidden;overflow-y:auto;--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);top:max(12px,var(--dsh-frame-overlay-top,12px));left:12px;height:auto;min-height:0;margin:0;overscroll-behavior:contain}
|
|
78
78
|
.dte .dte-pop-head{display:flex;justify-content:space-between;gap:16px;margin-bottom:8px;color:var(--dsw-alias-label-primary);font-weight:500}
|
|
79
|
-
.dte .dte-pop-title{display:inline-flex;align-items:center;gap:6px;min-width:0}
|
|
79
|
+
.dte .dte-pop-title{display:inline-flex;align-items:center;gap:6px;min-width:0;flex:1}
|
|
80
|
+
.dte .dte-pop-value{white-space:nowrap}
|
|
81
|
+
.dte button.dte-more{display:inline-flex;align-items:center;justify-content:center;border:0;background:transparent;min-height:24px;width:24px;padding:2px;margin:-3px -4px -3px -8px;color:var(--dsw-alias-label-tertiary)}
|
|
82
|
+
.dte .dte-pop-actions[hidden]{display:none}
|
|
80
83
|
.dte .dte-pop-rule{margin-bottom:10px;border-top:.5px solid var(--dsw-alias-border-l2)}
|
|
81
84
|
.dte .dte-summary{display:grid;grid-template-columns:minmax(76px,auto) minmax(0,1fr);gap:6px 16px;margin:0;color:var(--dsw-alias-label-tertiary)}
|
|
82
85
|
.dte .dte-summary dt,.dte .dte-summary dd{min-width:0;margin:0}
|
package/src/host/engine.ts
CHANGED
|
@@ -202,14 +202,24 @@ export class EstimatorEngine {
|
|
|
202
202
|
const input = this.initialInput.get(key) ?? (firstCall ? this.normalize(firstCall).promptTokens : null);
|
|
203
203
|
const requestMode = task.calls.findLast(call => call.mode !== 'unknown' && (!task.route
|
|
204
204
|
|| call.route.providerId === task.route.providerId && call.route.modelId === task.route.modelId && call.route.reasoningEffort === task.route.reasoningEffort))?.mode ?? 'unknown';
|
|
205
|
-
const
|
|
205
|
+
const route = this.route(task.route, requestMode);
|
|
206
|
+
const comparable = task.aiPlan ? [] : [...this.repository.tasks.values()].filter(record => {
|
|
207
|
+
const past = record.current, p = past.performance;
|
|
208
|
+
return record.id !== key && record.learned?.eligible && past.state === 'completed' && past.endedAt !== null
|
|
209
|
+
&& past.endedAt <= task.startedAt && record.savedAt > this.repository.resetAt
|
|
210
|
+
&& task.startedAt - past.endedAt <= this.config.historyRetentionDays * 86_400_000 && past.taskType === task.taskType
|
|
211
|
+
&& p?.providerId === route.providerId && p.modelId === route.modelId && p.endpointFingerprint === route.endpointFingerprint
|
|
212
|
+
&& p.reasoningEffort === route.reasoningEffort && p.serviceTier === route.serviceTier;
|
|
213
|
+
}).sort((a, b) => a.current.endedAt! - b.current.endedAt!).slice(-this.config.maxSamplesPerBucket);
|
|
214
|
+
const signature = JSON.stringify([task.taskType, task.explicitOutputWords, task.attachmentCount, task.route, requestMode, input, task.aiPlan, comparable.map(record => record.id)]);
|
|
206
215
|
const previous = this.plans.get(key);
|
|
207
216
|
if (previous?.signature === signature) return structuredClone(previous.plan);
|
|
208
|
-
const route = this.route(task.route, requestMode);
|
|
209
217
|
const templates = this.templates(route);
|
|
210
218
|
let plan = estimateWorkload({ prompt: syntheticPrompts[task.taskType], currentInputTokens: input, fileCount: task.attachmentCount,
|
|
211
219
|
...(task.explicitOutputWords ? { explicitOutputTokens: { low: task.explicitOutputWords, high: task.explicitOutputWords * 2 } } : {}) }, route,
|
|
212
|
-
{ config: this.engineConfig(), templates, history: [...this.workloadHistory.values()],
|
|
220
|
+
{ config: this.engineConfig(), templates, history: [...this.workloadHistory.values()],
|
|
221
|
+
observations: comparable.map(record => ({ requestCount: record.learned!.calls, visibleOutputTokens: record.learned!.visibleOutputTokens, toolMs: record.learned!.toolWorkMs })),
|
|
222
|
+
rules: [{ id: 'durable-classification', kind: task.taskType, patterns: ['.*'] }] });
|
|
213
223
|
if (task.aiPlan) plan = applyAiPlan(plan, task.aiPlan, route, this.currentInput.get(key) ?? input, this.engineConfig());
|
|
214
224
|
this.plans.set(key, { signature, plan });
|
|
215
225
|
return structuredClone(plan);
|
|
@@ -388,7 +398,10 @@ export class EstimatorEngine {
|
|
|
388
398
|
return low === null || high === null ? null : { low, high };
|
|
389
399
|
});
|
|
390
400
|
const outputScenarios = [plan.low, plan.high].map(scenario => scenario.requests.filter(request => !completedIds.includes(request.id))
|
|
391
|
-
.reduce((sum, request) =>
|
|
401
|
+
.reduce((sum, request) => {
|
|
402
|
+
const generated = progress[request.id]?.generatedTextTokens ?? 0;
|
|
403
|
+
return { low: sum.low + Math.max(0, request.visibleOutputTokens.low - generated), high: sum.high + Math.max(0, request.visibleOutputTokens.high - generated) };
|
|
404
|
+
}, { low: 0, high: 0 }));
|
|
392
405
|
const estimatedRemainingOutputTokens = task.endedAt !== null ? { low: 0, high: 0 } : errors || planExhausted ? null
|
|
393
406
|
: { low: Math.min(...outputScenarios.map(value => value.low)), high: Math.max(...outputScenarios.map(value => value.high)) };
|
|
394
407
|
const estimatedTokens = tokenScenarios.every((span): span is Span => span !== null)
|
package/src/host/index.ts
CHANGED
|
@@ -245,7 +245,7 @@ export async function apply(ctx: Context, rawConfig: unknown): Promise<void> {
|
|
|
245
245
|
name: 'task_estimate_plan',
|
|
246
246
|
description: 'Update the current task ETA from your assessment of the work still needed. Submit after understanding a multi-step task, and revise when its scope or remaining work materially changes. The Host combines the plan with this route’s observed response times. These are reference estimates, not completion promises.',
|
|
247
247
|
parameters: { type: 'object', properties: {
|
|
248
|
-
remainingCalls: { type: 'object', description: 'Future model
|
|
248
|
+
remainingCalls: { type: 'object', description: 'Future model response rounds after this reporting call, including final answer and verification; 1–500. Count one response containing many tool calls as ONE model request, not one per tool or file. Reuse the observed work pattern to bound remaining rounds.', properties: { low: { type: 'integer', minimum: 1, maximum: 500 }, high: { type: 'integer', minimum: 1, maximum: 500 } }, required: ['low', 'high'], additionalProperties: false },
|
|
249
249
|
remainingOutputTokens: { type: 'object', description: 'Total future visible text/tool-argument tokens, excluding hidden reasoning; ordered bounds, 0–2000000.', properties: { low: { type: 'integer', minimum: 0, maximum: 2000000 }, high: { type: 'integer', minimum: 0, maximum: 2000000 } }, required: ['low', 'high'], additionalProperties: false },
|
|
250
250
|
remainingToolSeconds: { type: 'object', description: 'Future tool execution time, excluding model requests and human waits; ordered bounds, 0–86400 seconds.', properties: { low: { type: 'integer', minimum: 0, maximum: 86400 }, high: { type: 'integer', minimum: 0, maximum: 86400 } }, required: ['low', 'high'], additionalProperties: false },
|
|
251
251
|
stage: { type: 'string', enum: ['planning', 'implementation', 'verification', 'finishing'] },
|
|
@@ -259,7 +259,7 @@ export async function apply(ctx: Context, rawConfig: unknown): Promise<void> {
|
|
|
259
259
|
},
|
|
260
260
|
});
|
|
261
261
|
ctx.systemPrompt.section({ name: 'task-estimate-planning', order: 1_000,
|
|
262
|
-
text: 'For a multi-step user task, submit a task_estimate_plan after understanding the scope, preferably alongside your initial work tools. Include implementation, verification and delivery. Revise it only when scope or remaining work materially changes or the budget is exceeded. Count work remaining after the reporting call. Use task_estimator snapshot route timings when useful; never invent measured performance or treat call count as percentage complete. Skip planning for trivial answers. This uses the executing agent; do not start another model request just to estimate or poll.' });
|
|
262
|
+
text: 'For a multi-step user task, submit a task_estimate_plan after understanding the scope, preferably alongside your initial work tools. Include implementation, verification and delivery. Revise it only when scope or remaining work materially changes or the budget is exceeded. Count work remaining after the reporting call. Count model response rounds, not individual tools, files, commands or parallel calls; one model response can contain many tools. Estimate tool wall time on the critical path, not the sum of parallel execution times. Prefer concrete bounds for the known scope and revise when evidence changes; do not add speculative optional work. Use task_estimator snapshot route timings when useful; never invent measured performance or treat call count as percentage complete. Skip planning for trivial answers. This uses the executing agent; do not start another model request just to estimate or poll.' });
|
|
263
263
|
// Existing sessions are folded once on demand by the projection owner. No log rescans per frame.
|
|
264
264
|
for (const agent of ctx.agents.list()) safe(agent, () => { projection.stateOf(agent.session); engine.runtime.task(agent.id, ctx.agents.turnBoundaryOf(agent.session).lastTurn); });
|
|
265
265
|
ctx.effect(() => async () => {
|
package/src/shared/wire.ts
CHANGED
|
@@ -130,5 +130,5 @@ export interface EstimatorRemote {
|
|
|
130
130
|
manage(action: ManagementAction, payload: string, signal?: AbortSignal): Promise<RemoteResult<ManagementResult>>;
|
|
131
131
|
}
|
|
132
132
|
export type RemoteResult<T> = { ok: true; value: T } | { ok: false; error: { message: string; code: string } };
|
|
133
|
-
export const ALGORITHM_VERSION = 'p0-0.2.
|
|
133
|
+
export const ALGORITHM_VERSION = 'p0-0.2.9';
|
|
134
134
|
export const PACKAGE_NAME = 'dsh-task-time-cost';
|
package/src/shared/workload.ts
CHANGED
|
@@ -30,6 +30,7 @@ export interface RequestBudget {
|
|
|
30
30
|
export interface ToolBudget { id: string; durationMs: Span; dependsOn: string[]; resource?: string; sourceKind: SourceKind }
|
|
31
31
|
export interface PlanScenario { requests: RequestBudget[]; tools: ToolBudget[] }
|
|
32
32
|
export interface WorkloadPlan { kind: TaskKind; low: PlanScenario; high: PlanScenario; origin: EstimateOrigin; reasons: string[]; workloadSamples: number; sourceIds: string[] }
|
|
33
|
+
export interface WorkloadSample { requestCount: number; visibleOutputTokens: number; toolMs: number }
|
|
33
34
|
export interface WorkloadHistory { kind: TaskKind; requestCount: number; visibleOutputTokens: number; toolMs: number; samples: number; lastObservedAtMs: number | null; sourceKind: 'local-measured' }
|
|
34
35
|
export interface WorkloadObservation {
|
|
35
36
|
kind: TaskKind;
|
|
@@ -146,7 +147,7 @@ export function classifyTask(input: TaskFeatures, rules: readonly Classification
|
|
|
146
147
|
return { kind: 'unknown', reasons: ['no-classification-match', 'classification:heuristic'] };
|
|
147
148
|
}
|
|
148
149
|
|
|
149
|
-
export function estimateWorkload(input: TaskFeatures, route: RouteIdentity, options: { config?: Partial<EstimatorConfig>; templates?: readonly WorkloadTemplate[]; rules?: readonly ClassificationRule[]; history?: WorkloadHistory[] } = {}): WorkloadPlan {
|
|
150
|
+
export function estimateWorkload(input: TaskFeatures, route: RouteIdentity, options: { config?: Partial<EstimatorConfig>; templates?: readonly WorkloadTemplate[]; rules?: readonly ClassificationRule[]; history?: WorkloadHistory[]; observations?: readonly WorkloadSample[] } = {}): WorkloadPlan {
|
|
150
151
|
const config = validateConfig(options.config);
|
|
151
152
|
const templates = options.templates ?? DEFAULT_TEMPLATES;
|
|
152
153
|
validateTemplates(templates);
|
|
@@ -161,7 +162,19 @@ export function estimateWorkload(input: TaskFeatures, route: RouteIdentity, opti
|
|
|
161
162
|
let output = { ...template.visibleOutputTokens };
|
|
162
163
|
let tool = { ...template.toolMs };
|
|
163
164
|
const reasons = [...classification.reasons, 'serial-conservative-dependencies', 'cache-ratio-unknown', 'reasoning-budget-unknown'];
|
|
164
|
-
|
|
165
|
+
const observations = (options.observations ?? []).filter(sample => Number.isSafeInteger(sample.requestCount) && sample.requestCount >= 1
|
|
166
|
+
&& Number.isSafeInteger(sample.visibleOutputTokens) && sample.visibleOutputTokens >= 0 && Number.isFinite(sample.toolMs) && sample.toolMs >= 0).slice(-config.maxSamplesPerBucket);
|
|
167
|
+
const empirical = observations.length >= config.minConditionalSamples;
|
|
168
|
+
if (empirical) {
|
|
169
|
+
// Recentring the original template never learns a narrower spread. Use the
|
|
170
|
+
// central observed distribution once comparable completed tasks support it.
|
|
171
|
+
const range = (key: keyof WorkloadSample): Span => {
|
|
172
|
+
const values = observations.map(sample => sample[key]).sort((a, b) => a - b);
|
|
173
|
+
return { low: values[Math.floor((values.length - 1) * 0.2)]!, high: values[Math.ceil((values.length - 1) * 0.8)]! };
|
|
174
|
+
};
|
|
175
|
+
count = range('requestCount'); output = range('visibleOutputTokens'); tool = range('toolMs');
|
|
176
|
+
reasons.push('local-workload-distribution');
|
|
177
|
+
} else if (history) {
|
|
165
178
|
for (const key of ['requestCount', 'visibleOutputTokens', 'toolMs', 'samples'] as const) nonnegative(history[key], key);
|
|
166
179
|
count = scaleRange(count, history.requestCount);
|
|
167
180
|
count = { low: Math.max(1, Math.floor(count.low)), high: Math.max(1, Math.ceil(count.high)) };
|
|
@@ -193,13 +206,14 @@ export function estimateWorkload(input: TaskFeatures, route: RouteIdentity, opti
|
|
|
193
206
|
output = { low: Math.max(output.low, length.low * artifacts), high: Math.max(output.high, length.high * artifacts * overhead) };
|
|
194
207
|
reasons.push(input.explicitOutputTokens ? 'explicit-output-token-budget' : 'requested-length-token-conversion:heuristic');
|
|
195
208
|
}
|
|
196
|
-
const origin: EstimateOrigin = history ? (template.sourceKind === 'local-measured' ? 'local-measured' : 'mixed') : template.sourceKind;
|
|
209
|
+
const origin: EstimateOrigin = empirical ? 'local-measured' : history ? (template.sourceKind === 'local-measured' ? 'local-measured' : 'mixed') : template.sourceKind;
|
|
197
210
|
return {
|
|
198
211
|
kind: classification.kind,
|
|
199
212
|
low: buildScenario(count.low, output.low, tool.low, input.currentInputTokens, route, classification.kind, template.sourceKind, config, false, length ? length.low * artifacts : null),
|
|
200
213
|
high: buildScenario(count.high, output.high, tool.high, input.currentInputTokens, route, classification.kind, template.sourceKind, config, true, length ? length.high * artifacts : null),
|
|
201
|
-
origin, reasons, workloadSamples: history?.samples ?? 0,
|
|
202
|
-
sourceIds:
|
|
214
|
+
origin, reasons, workloadSamples: empirical ? observations.length : history?.samples ?? 0,
|
|
215
|
+
sourceIds: empirical ? [`local-workload-distribution:${classification.kind}:${observations.length}`]
|
|
216
|
+
: [template.sourceId ?? `engineering-${template.kind}-v1`, ...(history ? [`local-workload:${history.kind}:${history.samples}`] : [])],
|
|
203
217
|
};
|
|
204
218
|
}
|
|
205
219
|
|