@cat-factory/app 0.194.0 → 0.195.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/app/components/initiative/InitiativePlanDecision.vue +134 -0
- package/app/components/initiative/InitiativePlanNotice.vue +69 -0
- package/app/components/initiative/InitiativePlanReview.vue +278 -254
- package/app/components/initiative/InitiativeTrackerWindow.vue +72 -25
- package/app/components/pipeline/AgentPromptEditor.vue +40 -0
- package/app/components/pipeline/OutputBudgetInput.vue +69 -0
- package/app/components/pipeline/PipelineBuilder.vue +42 -0
- package/app/composables/api/agentSettings.ts +32 -0
- package/app/composables/useApi.ts +2 -0
- package/app/stores/agentSettings.ts +86 -0
- package/app/stores/outputBudget.spec.ts +55 -0
- package/app/stores/pipelines/draftStepConfig.ts +23 -0
- package/app/types/agent-settings.ts +12 -0
- package/app/utils/initiative.spec.ts +43 -0
- package/app/utils/initiative.ts +23 -0
- package/i18n/locales/de.json +12 -3
- package/i18n/locales/en.json +12 -3
- package/i18n/locales/es.json +12 -3
- package/i18n/locales/fr.json +12 -3
- package/i18n/locales/he.json +12 -3
- package/i18n/locales/it.json +12 -3
- package/i18n/locales/ja.json +12 -3
- package/i18n/locales/pl.json +12 -3
- package/i18n/locales/tr.json +12 -3
- package/i18n/locales/uk.json +12 -3
- package/package.json +2 -2
|
@@ -23,10 +23,12 @@ import {
|
|
|
23
23
|
INITIATIVE_STATUS_LABEL_KEYS,
|
|
24
24
|
initiativeProgress,
|
|
25
25
|
pendingCheckpointPhase,
|
|
26
|
+
planReviewDocument,
|
|
26
27
|
} from '~/utils/initiative'
|
|
27
28
|
import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
|
|
28
29
|
import StepRunMeta from '~/components/panels/StepRunMeta.vue'
|
|
29
30
|
import InitiativePlanReview from '~/components/initiative/InitiativePlanReview.vue'
|
|
31
|
+
import InitiativePlanNotice from '~/components/initiative/InitiativePlanNotice.vue'
|
|
30
32
|
|
|
31
33
|
const board = useBoardStore()
|
|
32
34
|
const initiatives = useInitiativesStore()
|
|
@@ -61,6 +63,24 @@ const {
|
|
|
61
63
|
stepIndex: () => stepIndex.value,
|
|
62
64
|
})
|
|
63
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The `StepRunMeta` prop bundle, or null when no step speaks for this window. Bound as one object
|
|
68
|
+
* because it has two homes — the tracker's end-side column, and the plan review's sidebar while the
|
|
69
|
+
* review owns the window — and the run details must read identically in both.
|
|
70
|
+
*/
|
|
71
|
+
const runMeta = computed(() =>
|
|
72
|
+
metaStep.value
|
|
73
|
+
? {
|
|
74
|
+
step: metaStep.value,
|
|
75
|
+
instanceId: runId.value,
|
|
76
|
+
stepNumber: position.value,
|
|
77
|
+
totalSteps: totalSteps.value,
|
|
78
|
+
runFailed: runFailed.value,
|
|
79
|
+
failureAt: failureAt.value,
|
|
80
|
+
}
|
|
81
|
+
: null,
|
|
82
|
+
)
|
|
83
|
+
|
|
64
84
|
const phases = computed(() => initiative.value?.phases ?? [])
|
|
65
85
|
function itemsOf(phaseId: string): InitiativeItem[] {
|
|
66
86
|
return (initiative.value?.items ?? []).filter((i) => i.phaseId === phaseId)
|
|
@@ -103,9 +123,19 @@ async function checkpointControl(action: 'resume' | 'cancel') {
|
|
|
103
123
|
// ---- Plan review: the planner step's human gate, resolved right here -----------------------
|
|
104
124
|
// Derived from the BLOCK (via the shared planning composable), not from this window's own
|
|
105
125
|
// `stepIndex`: the card / inspector open the tracker with no step, and that is the entry point a
|
|
106
|
-
// human parked on the gate actually uses. So the
|
|
126
|
+
// human parked on the gate actually uses. So the review appears on every route into the window.
|
|
107
127
|
const { planApproval } = useInitiativePlanning(() => blockId.value ?? '')
|
|
108
128
|
|
|
129
|
+
/**
|
|
130
|
+
* The plan document the parked gate offers, or `''` — which is also the layout decision. A rendered
|
|
131
|
+
* plan is a REPLACEMENT for the tracker body rather than a card above it: the render reads the
|
|
132
|
+
* ingested entity, so the sections below would be a second copy of what the reviewer is reading,
|
|
133
|
+
* and everything the tracker adds on top (PR links, item curation, checkpoints, follow-ups) is
|
|
134
|
+
* execution-time state that cannot exist until the plan is committed. With no document, the tracker
|
|
135
|
+
* body IS the plan, so the gate takes the compact notice above it instead.
|
|
136
|
+
*/
|
|
137
|
+
const planDocument = computed(() => planReviewDocument(planApproval.value))
|
|
138
|
+
|
|
109
139
|
const policyRules = computed(() => initiative.value?.policy?.rules ?? [])
|
|
110
140
|
function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?: number }): string {
|
|
111
141
|
const axes = [
|
|
@@ -236,30 +266,54 @@ async function savePolicy() {
|
|
|
236
266
|
</UBadge>
|
|
237
267
|
</template>
|
|
238
268
|
|
|
239
|
-
|
|
269
|
+
<!-- The planner's human gate, with the plan rendered as a document. This window is where the
|
|
270
|
+
park ROUTES (the planner's archetype declares this result view), so it is the only surface
|
|
271
|
+
that can resolve it — and while it is parked the review OWNS the window: an outline sidebar,
|
|
272
|
+
the plan at full height, per-block commenting and the commands in an end-side rail, the same
|
|
273
|
+
tools and the same shape the step reader gives the architect's prose. The tracker body it
|
|
274
|
+
replaces would only repeat the plan (see `planDocument`). -->
|
|
275
|
+
<InitiativePlanReview
|
|
276
|
+
v-if="planApproval && planDocument"
|
|
277
|
+
:approval="planApproval.approval"
|
|
278
|
+
:instance-id="planApproval.instanceId"
|
|
279
|
+
:can-execute="access.canExecuteRuns.value"
|
|
280
|
+
:plan-document="planDocument"
|
|
281
|
+
>
|
|
282
|
+
<template v-if="runMeta" #run-details>
|
|
283
|
+
<StepRunMeta v-bind="runMeta" />
|
|
284
|
+
</template>
|
|
285
|
+
</InitiativePlanReview>
|
|
286
|
+
|
|
287
|
+
<div v-else class="flex min-h-0 flex-1">
|
|
240
288
|
<div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
|
|
241
|
-
<!--
|
|
289
|
+
<!-- A parked gate whose step rendered no plan: the commands, plus a notice pointing at the
|
|
290
|
+
sections below — which in that case are the only rendering of the plan there is.
|
|
291
|
+
Deliberately OUTSIDE the entity branch: the gate lives on the RUN, so it is parked
|
|
292
|
+
before `initiatives.load()` has resolved (and stays parked if it fails), and a window
|
|
293
|
+
that answered such a gate with the empty state alone would leave it unresolvable from
|
|
294
|
+
the UI. `hasSections` is what keeps the notice honest about whether the sections it
|
|
295
|
+
points at are actually rendered underneath. -->
|
|
296
|
+
<InitiativePlanNotice
|
|
297
|
+
v-if="planApproval"
|
|
298
|
+
:approval="planApproval.approval"
|
|
299
|
+
:instance-id="planApproval.instanceId"
|
|
300
|
+
:can-execute="access.canExecuteRuns.value"
|
|
301
|
+
:has-sections="!!initiative"
|
|
302
|
+
/>
|
|
303
|
+
|
|
304
|
+
<!-- No entity yet (module unwired / still creating). Centred in the column when it is the
|
|
305
|
+
only thing in it; merely inset when the notice above it means `h-full` would overflow
|
|
306
|
+
the scroller by the notice's own height. -->
|
|
242
307
|
<div
|
|
243
308
|
v-if="!initiative"
|
|
244
|
-
class="flex
|
|
309
|
+
class="flex flex-col items-center justify-center gap-2 text-center text-slate-400"
|
|
310
|
+
:class="planApproval ? 'py-16' : 'h-full'"
|
|
245
311
|
>
|
|
246
312
|
<UIcon name="i-lucide-milestone" class="h-8 w-8 opacity-40" />
|
|
247
313
|
<p class="text-sm">{{ t('initiative.tracker.empty') }}</p>
|
|
248
314
|
</div>
|
|
249
315
|
|
|
250
316
|
<template v-else>
|
|
251
|
-
<!-- The planner's human gate. This window is where the park ROUTES (the planner's
|
|
252
|
-
archetype declares this result view), so it is the only surface that can resolve
|
|
253
|
-
it — and the plan it judges is rendered as a navigable document with per-block
|
|
254
|
-
commenting, the same tools the step reader gives the architect's prose. -->
|
|
255
|
-
<InitiativePlanReview
|
|
256
|
-
v-if="planApproval"
|
|
257
|
-
:approval="planApproval.approval"
|
|
258
|
-
:instance-id="planApproval.instanceId"
|
|
259
|
-
:can-execute="access.canExecuteRuns.value"
|
|
260
|
-
:output-is-rendered="planApproval.outputIsRendered"
|
|
261
|
-
/>
|
|
262
|
-
|
|
263
317
|
<!-- Paused at a phase checkpoint (D2): a completed checkpoint phase is awaiting
|
|
264
318
|
review before the next phase spawns. Read the phase's artifacts/PRs below,
|
|
265
319
|
then resume (continue) or cancel (stop) the initiative right here. -->
|
|
@@ -646,18 +700,11 @@ async function savePolicy() {
|
|
|
646
700
|
through `useResultViewRunMeta`, so it is present on the card / inspector entry point
|
|
647
701
|
too — where this window carries no step index of its own. -->
|
|
648
702
|
<aside
|
|
649
|
-
v-if="
|
|
703
|
+
v-if="runMeta"
|
|
650
704
|
data-testid="initiative-tracker-run-meta"
|
|
651
705
|
class="hidden w-60 shrink-0 flex-col gap-4 overflow-y-auto border-s border-slate-800 bg-slate-900/50 px-4 py-4 lg:flex"
|
|
652
706
|
>
|
|
653
|
-
<StepRunMeta
|
|
654
|
-
:step="metaStep"
|
|
655
|
-
:instance-id="runId"
|
|
656
|
-
:step-number="position"
|
|
657
|
-
:total-steps="totalSteps"
|
|
658
|
-
:run-failed="runFailed"
|
|
659
|
-
:failure-at="failureAt"
|
|
660
|
-
/>
|
|
707
|
+
<StepRunMeta v-bind="runMeta" />
|
|
661
708
|
</aside>
|
|
662
709
|
</div>
|
|
663
710
|
</ResultWindowShell>
|
|
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue'
|
|
|
3
3
|
import type { AgentPromptRevision } from '~/types/agent-prompts'
|
|
4
4
|
import { agentKindMeta } from '~/utils/catalog'
|
|
5
5
|
import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
|
|
6
|
+
import OutputBudgetInput from '~/components/pipeline/OutputBudgetInput.vue'
|
|
6
7
|
import {
|
|
7
8
|
draftForRevision,
|
|
8
9
|
isDirty,
|
|
@@ -30,6 +31,7 @@ const emit = defineEmits<{ close: [] }>()
|
|
|
30
31
|
const { t } = useI18n()
|
|
31
32
|
const toast = useToast()
|
|
32
33
|
const prompts = useAgentPromptsStore()
|
|
34
|
+
const agentSettings = useAgentSettingsStore()
|
|
33
35
|
|
|
34
36
|
const open = computed({
|
|
35
37
|
get: () => props.agentKind !== null,
|
|
@@ -76,6 +78,29 @@ watch(
|
|
|
76
78
|
const detail = computed(() => prompts.detail)
|
|
77
79
|
const label = computed(() => (props.agentKind ? agentKindMeta(props.agentKind).label : ''))
|
|
78
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The workspace-wide output-token ceiling for this kind — the same per-agent-kind scope this
|
|
83
|
+
* editor already owns for the prompt, which is why it lives here rather than in a settings screen
|
|
84
|
+
* of its own. A pipeline step may still pin its own budget over it.
|
|
85
|
+
*
|
|
86
|
+
* Saved on its own, immediately: unlike the prompt (whose save appends a revision and wants an
|
|
87
|
+
* explicit commit) this is one scalar with no history, so a separate Save button would only invite
|
|
88
|
+
* someone to type a number, close the modal and wonder why nothing changed.
|
|
89
|
+
*/
|
|
90
|
+
const budget = computed(() =>
|
|
91
|
+
props.agentKind ? agentSettings.maxOutputTokensFor(props.agentKind) : undefined,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
async function saveBudget(value: number | null) {
|
|
95
|
+
const kind = props.agentKind
|
|
96
|
+
if (!kind) return
|
|
97
|
+
try {
|
|
98
|
+
await agentSettings.setMaxOutputTokens(kind, value)
|
|
99
|
+
} catch {
|
|
100
|
+
toast.add({ title: t('agentPrompt.toast.budgetFailed'), color: 'error' })
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
79
104
|
/** The live prompt, so "no change" can be reported instead of appending an identical revision. */
|
|
80
105
|
const dirty = computed(() => isDirty(draft.value, detail.value))
|
|
81
106
|
/** Nothing to revert to when the workspace is already running the shipped prompt. */
|
|
@@ -164,6 +189,21 @@ function revisionLabel(revision: AgentPromptRevision): string {
|
|
|
164
189
|
</UBadge>
|
|
165
190
|
</div>
|
|
166
191
|
|
|
192
|
+
<!-- The workspace-wide output ceiling for this kind. Same per-agent-kind scope as the
|
|
193
|
+
prompt below it; saves on change, since there is no revision log to commit to. -->
|
|
194
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
195
|
+
<span class="text-[11px] text-slate-400">{{ t('pipeline.outputBudget.kindLabel') }}</span>
|
|
196
|
+
<OutputBudgetInput
|
|
197
|
+
class="w-32"
|
|
198
|
+
:model-value="budget"
|
|
199
|
+
:disabled="agentSettings.saving"
|
|
200
|
+
@update:model-value="saveBudget"
|
|
201
|
+
/>
|
|
202
|
+
<span class="text-[10px] text-slate-500">
|
|
203
|
+
{{ t('pipeline.outputBudget.kindHint') }}
|
|
204
|
+
</span>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
167
207
|
<!-- What the platform appends is SHOWN, not described. A prose summary of it is copy
|
|
168
208
|
that silently goes stale the moment a directive is added, and a user who does not
|
|
169
209
|
know what is already there writes a prompt that fights it. -->
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The output-token ceiling control, shared by both tiers that configure one: a pipeline step's
|
|
4
|
+
* own override (in the builder) and a workspace's per-agent-kind default (in the prompt editor).
|
|
5
|
+
*
|
|
6
|
+
* Empty means INHERIT, and that is the whole reason this is a component rather than a bare
|
|
7
|
+
* `UInput type="number"` at each call site: "no value" has to round-trip as `null` and never as
|
|
8
|
+
* `0`, or clearing the field would send a zero-token ceiling — every reply empty — instead of
|
|
9
|
+
* falling back to the next tier. The bounds come from the contract, so the input cannot offer a
|
|
10
|
+
* value the server would reject.
|
|
11
|
+
*/
|
|
12
|
+
import { computed } from 'vue'
|
|
13
|
+
import { MAX_AGENT_MAX_OUTPUT_TOKENS, MIN_AGENT_MAX_OUTPUT_TOKENS } from '~/types/agent-settings'
|
|
14
|
+
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
/** The configured ceiling, or null/undefined when this tier inherits. */
|
|
17
|
+
modelValue: number | null | undefined
|
|
18
|
+
/** What this tier falls back to, shown as the placeholder so "inherit" names a number. */
|
|
19
|
+
inheritedValue?: number | undefined
|
|
20
|
+
disabled?: boolean
|
|
21
|
+
}>()
|
|
22
|
+
|
|
23
|
+
const emit = defineEmits<{ 'update:modelValue': [number | null] }>()
|
|
24
|
+
|
|
25
|
+
const { t, n } = useI18n()
|
|
26
|
+
|
|
27
|
+
/** Bound to the input as a string so an empty field is distinguishable from a typed 0. */
|
|
28
|
+
const text = computed(() => (props.modelValue != null ? String(props.modelValue) : ''))
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The placeholder names the inherited ceiling when the caller knows it, so a user reading an
|
|
32
|
+
* empty field learns what the step will actually run on rather than just that it is unset.
|
|
33
|
+
*/
|
|
34
|
+
const placeholder = computed(() =>
|
|
35
|
+
props.inheritedValue != null
|
|
36
|
+
? t('pipeline.outputBudget.inheritsValue', { tokens: n(props.inheritedValue) })
|
|
37
|
+
: t('pipeline.outputBudget.inherits'),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Commit on change. An empty/unparseable field clears to `null` (inherit); anything else is
|
|
42
|
+
* clamped into the contract's range rather than rejected, so a fat-fingered extra digit lands on
|
|
43
|
+
* the ceiling instead of silently failing the save with a 422.
|
|
44
|
+
*/
|
|
45
|
+
function commit(raw: string | number) {
|
|
46
|
+
const trimmed = String(raw).trim()
|
|
47
|
+
if (trimmed === '') return emit('update:modelValue', null)
|
|
48
|
+
const parsed = Number.parseInt(trimmed, 10)
|
|
49
|
+
if (!Number.isFinite(parsed)) return emit('update:modelValue', null)
|
|
50
|
+
const clamped = Math.min(
|
|
51
|
+
MAX_AGENT_MAX_OUTPUT_TOKENS,
|
|
52
|
+
Math.max(MIN_AGENT_MAX_OUTPUT_TOKENS, parsed),
|
|
53
|
+
)
|
|
54
|
+
emit('update:modelValue', clamped)
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<template>
|
|
59
|
+
<UInput
|
|
60
|
+
:model-value="text"
|
|
61
|
+
type="number"
|
|
62
|
+
size="xs"
|
|
63
|
+
:min="MIN_AGENT_MAX_OUTPUT_TOKENS"
|
|
64
|
+
:max="MAX_AGENT_MAX_OUTPUT_TOKENS"
|
|
65
|
+
:placeholder="placeholder"
|
|
66
|
+
:disabled="disabled"
|
|
67
|
+
@change="commit(($event.target as HTMLInputElement).value)"
|
|
68
|
+
/>
|
|
69
|
+
</template>
|
|
@@ -5,6 +5,7 @@ import type { AgentKind, Pipeline, PipelinePurpose } from '~/types/domain'
|
|
|
5
5
|
import AgentPalette from '~/components/palettes/AgentPalette.vue'
|
|
6
6
|
import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
|
|
7
7
|
import AgentPromptEditor from '~/components/pipeline/AgentPromptEditor.vue'
|
|
8
|
+
import OutputBudgetInput from '~/components/pipeline/OutputBudgetInput.vue'
|
|
8
9
|
import { showOverrideField } from '~/utils/uiMode'
|
|
9
10
|
import {
|
|
10
11
|
agentKindMeta,
|
|
@@ -86,6 +87,7 @@ const agents = useAgentsStore()
|
|
|
86
87
|
const ui = useUiStore()
|
|
87
88
|
const uiMode = useUiModeStore()
|
|
88
89
|
const agentPrompts = useAgentPromptsStore()
|
|
90
|
+
const agentSettings = useAgentSettingsStore()
|
|
89
91
|
|
|
90
92
|
// The agent kind whose system prompt is open in the editor (null = closed). Per-KIND, not
|
|
91
93
|
// per-step: an override applies to every run of that agent in the workspace, so two steps of
|
|
@@ -102,6 +104,26 @@ const promptEditorKind = ref<AgentKind | null>(null)
|
|
|
102
104
|
function showPromptEditor(kind: AgentKind): boolean {
|
|
103
105
|
return showOverrideField(uiMode.isAdvanced, agentPrompts.isCustomized(kind) || null)
|
|
104
106
|
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whether this step's own output-budget field shows. Same override rule as the prompt editor: a
|
|
110
|
+
* step with no pinned ceiling inherits, so the control is advanced-only until a value actually
|
|
111
|
+
* exists — at which point it must be visible in BOTH tiers, or a basic-mode user runs on a budget
|
|
112
|
+
* a teammate pinned and they can neither see nor clear.
|
|
113
|
+
*/
|
|
114
|
+
function showOutputBudget(index: number): boolean {
|
|
115
|
+
return showOverrideField(uiMode.isAdvanced, pipelines.draftMaxOutputTokens(index) ?? null)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* What a step with no pinned ceiling actually runs on: the workspace's per-kind setting when it
|
|
120
|
+
* has one. Shown as the field's placeholder. Undefined ⇒ the deployment default, which the SPA
|
|
121
|
+
* deliberately does NOT guess at — it is env-resolved per agent kind and a number invented here
|
|
122
|
+
* would be wrong on the deployments that tuned it.
|
|
123
|
+
*/
|
|
124
|
+
function inheritedOutputBudget(kind: AgentKind): number | undefined {
|
|
125
|
+
return agentSettings.maxOutputTokensFor(kind)
|
|
126
|
+
}
|
|
105
127
|
const releaseHealth = useReleaseHealthStore()
|
|
106
128
|
const skills = useSkillsStore()
|
|
107
129
|
|
|
@@ -151,6 +173,9 @@ watch(open, (isOpen) => {
|
|
|
151
173
|
// Best-effort: the builder is fully usable without it, and a deployment that wires no
|
|
152
174
|
// override store answers 503 here.
|
|
153
175
|
if (isOpen) agentPrompts.loadIndex().catch(() => {})
|
|
176
|
+
// The workspace's per-kind output ceilings, which the per-step field shows as its inherited
|
|
177
|
+
// placeholder and the prompt editor edits. Best-effort on the same terms as the prompt index.
|
|
178
|
+
if (isOpen) agentSettings.load().catch(() => {})
|
|
154
179
|
})
|
|
155
180
|
|
|
156
181
|
function add(kind: AgentKind) {
|
|
@@ -694,6 +719,23 @@ async function clone(p: Pipeline) {
|
|
|
694
719
|
</p>
|
|
695
720
|
</div>
|
|
696
721
|
|
|
722
|
+
<!-- This step's own output-token ceiling. An OVERRIDE of the workspace's per-kind
|
|
723
|
+
setting (itself an override of the deployment routing default), so it is
|
|
724
|
+
advanced-only until a value is pinned; empty inherits. -->
|
|
725
|
+
<div v-if="showOutputBudget(unit.index)" class="ms-6 flex items-center gap-2">
|
|
726
|
+
<span class="text-[10px] text-slate-500">
|
|
727
|
+
{{ t('pipeline.outputBudget.stepLabel') }}
|
|
728
|
+
</span>
|
|
729
|
+
<OutputBudgetInput
|
|
730
|
+
class="w-28"
|
|
731
|
+
:model-value="pipelines.draftMaxOutputTokens(unit.index)"
|
|
732
|
+
:inherited-value="inheritedOutputBudget(unit.kind)"
|
|
733
|
+
@update:model-value="
|
|
734
|
+
pipelines.setDraftMaxOutputTokens(unit.index, $event ?? undefined)
|
|
735
|
+
"
|
|
736
|
+
/>
|
|
737
|
+
</div>
|
|
738
|
+
|
|
697
739
|
<!-- Attached companion: a dependent reviewer for this producer, optionally
|
|
698
740
|
gated on the task estimate. -->
|
|
699
741
|
<div
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
listWorkspaceAgentSettingsContract,
|
|
3
|
+
updateWorkspaceAgentSettingsContract,
|
|
4
|
+
} from '@cat-factory/contracts'
|
|
5
|
+
import type { UpdateWorkspaceAgentSettingsInput } from '~/types/agent-settings'
|
|
6
|
+
import type { ApiContext } from './context'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The workspace's per-agent-kind generation settings, edited from the pipeline builder. There is
|
|
10
|
+
* no delete route: clearing the last configured field IS the way back to the deployment default,
|
|
11
|
+
* and the server drops the row when nothing is left set.
|
|
12
|
+
*/
|
|
13
|
+
export function agentSettingsApi({ send, ws }: ApiContext) {
|
|
14
|
+
return {
|
|
15
|
+
// Every kind the workspace has configured — nothing for a kind that inherits.
|
|
16
|
+
listWorkspaceAgentSettings: (workspaceId: string) =>
|
|
17
|
+
send(listWorkspaceAgentSettingsContract, { pathPrefix: ws(workspaceId) }),
|
|
18
|
+
|
|
19
|
+
// Patch one kind. An explicit null clears the field; the response is null once the kind is
|
|
20
|
+
// back to inheriting entirely.
|
|
21
|
+
updateWorkspaceAgentSettings: (
|
|
22
|
+
workspaceId: string,
|
|
23
|
+
agentKind: string,
|
|
24
|
+
body: UpdateWorkspaceAgentSettingsInput,
|
|
25
|
+
) =>
|
|
26
|
+
send(updateWorkspaceAgentSettingsContract, {
|
|
27
|
+
pathPrefix: ws(workspaceId),
|
|
28
|
+
pathParams: { agentKind },
|
|
29
|
+
body,
|
|
30
|
+
}),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -3,6 +3,7 @@ import { createApiClient, createSend, createSendWith } from './api/client'
|
|
|
3
3
|
import type { ApiContext } from './api/context'
|
|
4
4
|
import { accountsApi } from './api/accounts'
|
|
5
5
|
import { agentPromptsApi } from './api/agentPrompts'
|
|
6
|
+
import { agentSettingsApi } from './api/agentSettings'
|
|
6
7
|
import { platformObservabilityApi } from './api/platformObservability'
|
|
7
8
|
import { reportsApi } from './api/reports'
|
|
8
9
|
import { authApi } from './api/auth'
|
|
@@ -136,6 +137,7 @@ export function useApi() {
|
|
|
136
137
|
...notificationsApi(ctx),
|
|
137
138
|
...presetsApi(ctx),
|
|
138
139
|
...agentPromptsApi(ctx),
|
|
140
|
+
...agentSettingsApi(ctx),
|
|
139
141
|
...preflightsApi(ctx),
|
|
140
142
|
...publicApiKeysApi(ctx),
|
|
141
143
|
...sharedStacksApi(ctx),
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import type { WorkspaceAgentSettings } from '~/types/agent-settings'
|
|
4
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The workspace's per-agent-kind generation settings — the pipeline builder's output-budget
|
|
8
|
+
* control, sitting beside the prompt editor.
|
|
9
|
+
*
|
|
10
|
+
* Unlike the prompt store there is only ONE shape to load: the whole configured set is a handful
|
|
11
|
+
* of small rows (a kind that inherits has no row at all), so the index IS the detail and the
|
|
12
|
+
* builder can badge every step and populate its editor from one request. There is nothing here
|
|
13
|
+
* worth deferring the way a prompt body is.
|
|
14
|
+
*/
|
|
15
|
+
export const useAgentSettingsStore = defineStore('agentSettings', () => {
|
|
16
|
+
const api = useApi()
|
|
17
|
+
|
|
18
|
+
const settings = ref<WorkspaceAgentSettings[]>([])
|
|
19
|
+
const loading = ref(false)
|
|
20
|
+
const saving = ref(false)
|
|
21
|
+
|
|
22
|
+
/** Configured ceilings by agent kind, for O(1) lookup while rendering a pipeline's steps. */
|
|
23
|
+
const ceilingByKind = computed(() => {
|
|
24
|
+
const out = new Map<string, number>()
|
|
25
|
+
for (const row of settings.value) {
|
|
26
|
+
if (row.maxOutputTokens != null) out.set(row.agentKind, row.maxOutputTokens)
|
|
27
|
+
}
|
|
28
|
+
return out
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
/** This kind's configured ceiling, or undefined when it inherits the deployment default. */
|
|
32
|
+
function maxOutputTokensFor(agentKind: string): number | undefined {
|
|
33
|
+
return ceilingByKind.value.get(agentKind)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Load the configured set. Best-effort like the prompt index: the builder is fully usable
|
|
38
|
+
* without it (every kind simply runs the deployment ceiling), and the endpoint 503s on a
|
|
39
|
+
* deployment that wires no settings store at all.
|
|
40
|
+
*/
|
|
41
|
+
async function load() {
|
|
42
|
+
const ws = useWorkspaceStore()
|
|
43
|
+
if (!ws.workspaceId) return
|
|
44
|
+
loading.value = true
|
|
45
|
+
try {
|
|
46
|
+
settings.value = await api.listWorkspaceAgentSettings(ws.requireId())
|
|
47
|
+
} finally {
|
|
48
|
+
loading.value = false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Set (or clear, with `null`) one kind's output-token ceiling.
|
|
54
|
+
*
|
|
55
|
+
* Reconciles from the row the SERVER returned rather than the value sent: it answers `null`
|
|
56
|
+
* once the kind is back to inheriting, which is the same signal the row should disappear —
|
|
57
|
+
* so a clear and a set both land through one code path and the store can never keep a row the
|
|
58
|
+
* server has dropped.
|
|
59
|
+
*/
|
|
60
|
+
async function setMaxOutputTokens(agentKind: string, maxOutputTokens: number | null) {
|
|
61
|
+
const ws = useWorkspaceStore()
|
|
62
|
+
saving.value = true
|
|
63
|
+
try {
|
|
64
|
+
const updated = await api.updateWorkspaceAgentSettings(ws.requireId(), agentKind, {
|
|
65
|
+
maxOutputTokens,
|
|
66
|
+
})
|
|
67
|
+
const rest = settings.value.filter((s) => s.agentKind !== agentKind)
|
|
68
|
+
settings.value = updated
|
|
69
|
+
? [...rest, updated].sort((a, b) => a.agentKind.localeCompare(b.agentKind))
|
|
70
|
+
: rest
|
|
71
|
+
return updated
|
|
72
|
+
} finally {
|
|
73
|
+
saving.value = false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
settings,
|
|
79
|
+
loading,
|
|
80
|
+
saving,
|
|
81
|
+
ceilingByKind,
|
|
82
|
+
maxOutputTokensFor,
|
|
83
|
+
load,
|
|
84
|
+
setMaxOutputTokens,
|
|
85
|
+
}
|
|
86
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { usePipelinesStore } from '~/stores/pipelines'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The per-step output-token ceiling rides the shared `StepOptions` bag, so its helpers owe the
|
|
6
|
+
* same normalization every other field in there follows: merge rather than clobber, and drop the
|
|
7
|
+
* whole entry once the bag empties. That last part is what keeps an all-default pipeline from
|
|
8
|
+
* persisting a `step_options` array of empty objects.
|
|
9
|
+
*/
|
|
10
|
+
describe('pipelines store — per-step output budget', () => {
|
|
11
|
+
it('sets, reads and clears a draft step’s ceiling', () => {
|
|
12
|
+
const pipelines = usePipelinesStore()
|
|
13
|
+
pipelines.addToDraft('doc-researcher')
|
|
14
|
+
expect(pipelines.draftMaxOutputTokens(0)).toBeUndefined()
|
|
15
|
+
|
|
16
|
+
pipelines.setDraftMaxOutputTokens(0, 24_000)
|
|
17
|
+
expect(pipelines.draftMaxOutputTokens(0)).toBe(24_000)
|
|
18
|
+
expect(pipelines.draftStepOptions[0]).toEqual({ maxOutputTokens: 24_000 })
|
|
19
|
+
|
|
20
|
+
// Clearing drops the field and, with the bag now empty, normalizes the entry back to null —
|
|
21
|
+
// so a step back on the inherited budget persists no options at all.
|
|
22
|
+
pipelines.setDraftMaxOutputTokens(0, undefined)
|
|
23
|
+
expect(pipelines.draftMaxOutputTokens(0)).toBeUndefined()
|
|
24
|
+
expect(pipelines.draftStepOptions[0]).toBeNull()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('merges with the other options on the step rather than clobbering the bag', () => {
|
|
28
|
+
const pipelines = usePipelinesStore()
|
|
29
|
+
pipelines.addToDraft('requirements-review')
|
|
30
|
+
pipelines.toggleDraftAutoRecommend(0)
|
|
31
|
+
expect(pipelines.draftStepOptions[0]).toEqual({ autoRecommend: false })
|
|
32
|
+
|
|
33
|
+
pipelines.setDraftMaxOutputTokens(0, 12_000)
|
|
34
|
+
expect(pipelines.draftStepOptions[0]).toEqual({
|
|
35
|
+
autoRecommend: false,
|
|
36
|
+
maxOutputTokens: 12_000,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// And clearing one field leaves the other standing (the entry stays, since the bag is not empty).
|
|
40
|
+
pipelines.setDraftMaxOutputTokens(0, undefined)
|
|
41
|
+
expect(pipelines.draftStepOptions[0]).toEqual({ autoRecommend: false })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('keeps each step’s ceiling independent', () => {
|
|
45
|
+
const pipelines = usePipelinesStore()
|
|
46
|
+
pipelines.addToDraft('doc-researcher')
|
|
47
|
+
pipelines.addToDraft('doc-outliner')
|
|
48
|
+
|
|
49
|
+
pipelines.setDraftMaxOutputTokens(0, 24_000)
|
|
50
|
+
pipelines.setDraftMaxOutputTokens(1, 10_000)
|
|
51
|
+
|
|
52
|
+
expect(pipelines.draftMaxOutputTokens(0)).toBe(24_000)
|
|
53
|
+
expect(pipelines.draftMaxOutputTokens(1)).toBe(10_000)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -139,6 +139,27 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
|
|
|
139
139
|
draftStepOptions.value[index] = Object.keys(next).length ? next : null
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The output-token ceiling pinned on the draft step at `index`, or undefined when the step
|
|
144
|
+
* inherits (the workspace's per-kind setting, else the deployment default).
|
|
145
|
+
*/
|
|
146
|
+
function draftMaxOutputTokens(index: number): number | undefined {
|
|
147
|
+
return draftStepOptions.value[index]?.maxOutputTokens
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Set (or clear) this step's own output-token ceiling. Merges into the step's `StepOptions`
|
|
152
|
+
* bag rather than clobbering it; clearing drops the field and, if the bag empties, the whole
|
|
153
|
+
* entry — so a step back on the inherited budget persists no options at all, exactly like the
|
|
154
|
+
* other fields here.
|
|
155
|
+
*/
|
|
156
|
+
function setDraftMaxOutputTokens(index: number, maxOutputTokens: number | undefined) {
|
|
157
|
+
const next: StepOptions = { ...draftStepOptions.value[index] }
|
|
158
|
+
if (maxOutputTokens != null) next.maxOutputTokens = maxOutputTokens
|
|
159
|
+
else delete next.maxOutputTokens
|
|
160
|
+
draftStepOptions.value[index] = Object.keys(next).length ? next : null
|
|
161
|
+
}
|
|
162
|
+
|
|
142
163
|
return {
|
|
143
164
|
toggleDraftGating,
|
|
144
165
|
toggleDraftConsensus,
|
|
@@ -153,5 +174,7 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
|
|
|
153
174
|
toggleDraftAutoRecommend,
|
|
154
175
|
draftSkillId,
|
|
155
176
|
setDraftSkillId,
|
|
177
|
+
draftMaxOutputTokens,
|
|
178
|
+
setDraftMaxOutputTokens,
|
|
156
179
|
}
|
|
157
180
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Per-workspace, per-agent-kind generation settings, mirroring `@cat-factory/contracts`
|
|
2
|
+
// (agent-settings.ts). Today one knob: the output-token ceiling a kind's inline calls run under.
|
|
3
|
+
// A kind absent from the store inherits the deployment routing default, and a pipeline step's own
|
|
4
|
+
// `stepOptions.maxOutputTokens` still overrides whatever is set here.
|
|
5
|
+
//
|
|
6
|
+
// All wire shapes are sourced from @cat-factory/contracts (single source of truth).
|
|
7
|
+
|
|
8
|
+
export type {
|
|
9
|
+
UpdateWorkspaceAgentSettingsInput,
|
|
10
|
+
WorkspaceAgentSettings,
|
|
11
|
+
} from '@cat-factory/contracts'
|
|
12
|
+
export { MAX_AGENT_MAX_OUTPUT_TOKENS, MIN_AGENT_MAX_OUTPUT_TOKENS } from '@cat-factory/contracts'
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
isPendingQuestion,
|
|
10
10
|
orderInterviewQuestions,
|
|
11
11
|
pendingCheckpointPhase,
|
|
12
|
+
planReviewDocument,
|
|
12
13
|
selectPlanApproval,
|
|
13
14
|
} from './initiative'
|
|
14
15
|
|
|
@@ -205,6 +206,48 @@ describe('selectPlanApproval', () => {
|
|
|
205
206
|
})
|
|
206
207
|
})
|
|
207
208
|
|
|
209
|
+
// Which shape the plan gate takes in the tracker window: a document review that OWNS the window, or
|
|
210
|
+
// the compact notice above the tracker's own sections. Both the window's layout and the review
|
|
211
|
+
// surface read this one value, so these pin the cases where "there is a plan to read" is not the
|
|
212
|
+
// same as "the proposal is non-empty".
|
|
213
|
+
|
|
214
|
+
describe('planReviewDocument', () => {
|
|
215
|
+
const gate = (proposal: string | null | undefined, outputIsRendered: boolean) => ({
|
|
216
|
+
approval: { proposal },
|
|
217
|
+
outputIsRendered,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('is the proposal when the step says it IS the plan rendering', () => {
|
|
221
|
+
expect(planReviewDocument(gate('# Initiative plan\n\n## Goal', true))).toBe(
|
|
222
|
+
'# Initiative plan\n\n## Goal',
|
|
223
|
+
)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('returns the proposal verbatim, so comment anchors stay on the lines they quote', () => {
|
|
227
|
+
// Anchoring is by SOURCE LINE, so trimming a leading newline would shift every anchor up one.
|
|
228
|
+
expect(planReviewDocument(gate('\n# Initiative plan\n', true))).toBe('\n# Initiative plan\n')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('reads an un-rendered proposal as no document, however substantial it looks', () => {
|
|
232
|
+
// The planner's transcript summary: a perfectly non-empty string that is not the plan. Showing
|
|
233
|
+
// it under a table of contents is the failure the rendered review exists to end.
|
|
234
|
+
expect(
|
|
235
|
+
planReviewDocument(gate('I drafted a three-phase plan and stopped for review.', false)),
|
|
236
|
+
).toBe('')
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('reads a rendered but blank proposal as no document', () => {
|
|
240
|
+
expect(planReviewDocument(gate(' \n ', true))).toBe('')
|
|
241
|
+
expect(planReviewDocument(gate(null, true))).toBe('')
|
|
242
|
+
expect(planReviewDocument(gate(undefined, true))).toBe('')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('has no document when nothing is parked', () => {
|
|
246
|
+
expect(planReviewDocument(null)).toBe('')
|
|
247
|
+
expect(planReviewDocument(undefined)).toBe('')
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
208
251
|
/**
|
|
209
252
|
* These tables are the reason the initiative card and the inspector word one park identically,
|
|
210
253
|
* and they are exactly the shape both i18n drift guards are blind to: the typed-key check and
|