@nauticana/sail 1.1.34 → 1.1.36

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.
Files changed (47) hide show
  1. package/README.md +23 -16
  2. package/package.json +1 -1
  3. package/src/agent/component/approval_inbox.html +93 -22
  4. package/src/agent/component/approval_inbox.ts +199 -17
  5. package/src/agent/component/assistant_activity.html +28 -0
  6. package/src/agent/component/assistant_activity.ts +40 -0
  7. package/src/agent/component/studio_page.html +35 -1
  8. package/src/agent/component/studio_page.ts +67 -10
  9. package/src/agent/component/turn_thread.html +27 -0
  10. package/src/agent/component/turn_thread.ts +94 -0
  11. package/src/agent/index.ts +2 -0
  12. package/src/agent/model/agent_studio.ts +67 -1
  13. package/src/agent/model/approval.ts +45 -1
  14. package/src/agent/model/conversation.ts +35 -1
  15. package/src/agent/model/decision.ts +20 -8
  16. package/src/agent/service/agent_studio.service.ts +29 -0
  17. package/src/agent/service/approval.service.ts +24 -4
  18. package/src/agent/service/conversation.service.ts +10 -0
  19. package/src/component/agency/agency_accept.ts +2 -8
  20. package/src/component/agency/agency_earnings_summary.html +3 -3
  21. package/src/component/agency/agency_earnings_summary.ts +2 -2
  22. package/src/component/agency/agency_payout_history.html +1 -1
  23. package/src/component/agency/agency_payout_history.ts +2 -3
  24. package/src/component/agency/commission_ledger.html +1 -1
  25. package/src/component/agency/commission_ledger.ts +2 -3
  26. package/src/component/billing/plan_selector.ts +1 -1
  27. package/src/component/billing/price_selector.ts +5 -5
  28. package/src/component/oauth_connection/oauth_connection_panel.ts +2 -1
  29. package/src/component/payout/bank_info_form.html +15 -6
  30. package/src/component/payout/bank_info_form.ts +28 -72
  31. package/src/component/security/api_keys.html +85 -0
  32. package/src/component/security/api_keys.ts +96 -0
  33. package/src/component/security/connected_apps.html +25 -0
  34. package/src/component/security/connected_apps.ts +40 -0
  35. package/src/index.ts +7 -0
  36. package/src/model/access.ts +37 -0
  37. package/src/model/appdata.ts +41 -67
  38. package/src/model/geo.ts +12 -0
  39. package/src/service/access.service.ts +56 -0
  40. package/src/service/auth.service.ts +2 -1
  41. package/src/service/caption_pipe.ts +8 -2
  42. package/src/service/currency.service.ts +51 -0
  43. package/src/service/money_pipe.ts +27 -0
  44. package/src/service/rest_service.ts +1 -1
  45. package/src/service/rest_url.ts +3 -0
  46. package/src/util/errors.ts +5 -4
  47. package/src/util/money.ts +18 -34
@@ -10,7 +10,7 @@
10
10
  <button type="button" class="studio__agent" [class.selected]="agent.agent_name === selectedName()"
11
11
  [attr.aria-current]="agent.agent_name === selectedName()" (click)="select(agent.agent_name)">
12
12
  <span class="studio__agent-name">{{ agent.display_name }}</span>
13
- <span [class]="'studio__readiness studio__readiness--' + agent.readiness">{{ agent.readiness }}</span>
13
+ <span [class]="'studio__readiness studio__readiness--' + agent.readiness">{{ readinessCaption(agent.readiness) }}</span>
14
14
  <span class="studio__agent-meta">
15
15
  @if (agent.is_default) { <span>Default</span> }
16
16
  @if (agent.archived) { <span>Archived</span> }
@@ -232,6 +232,40 @@
232
232
  </ul>
233
233
  }
234
234
  }
235
+ @case ('guardrails') {
236
+ @if (guardrails(); as g) {
237
+ @if (g.problem) { <p class="studio__error" role="alert">{{ g.problem }}</p> }
238
+ @for (layer of g.inherited; track layer.layer) {
239
+ <h3>Inherited · layer {{ layer.layer }}@if (layer.digest) { · {{ layer.digest.slice(0, 12) }} }</h3>
240
+ <pre class="studio__rules">{{ layer.rules | json }}</pre>
241
+ }
242
+ <h3>This agent's rules</h3>
243
+ <p>Rules only add to the inherited ones or tighten a rule of the same id.</p>
244
+ <mat-form-field class="studio__rules-editor">
245
+ <mat-label>Rules (JSON array)</mat-label>
246
+ <textarea matInput rows="10" #rules [value]="g.rules | json" [disabled]="!can('EDIT', undefined, 'guardrails')"></textarea>
247
+ </mat-form-field>
248
+ @if (can('EDIT', undefined, 'guardrails')) {
249
+ <button matButton="elevated" type="button" [disabled]="busy()" (click)="saveGuardrails(rules.value)">Save rules</button>
250
+ }
251
+ <h3>Safety events</h3>
252
+ <ul class="studio__safety-events">
253
+ @for (event of safetyEvents(); track event.id) {
254
+ <li>
255
+ {{ event.occurred_at | date: 'medium' }} · {{ event.stage }} · layer {{ event.layer }} · {{ event.action }} ({{ event.severity }})
256
+ · rules {{ event.rule_ids.join(', ') }}
257
+ @if (event.version) { · v{{ event.version }} }
258
+ @if (event.actor_kind) { · {{ event.actor_kind }} {{ event.actor_id }} }
259
+ </li>
260
+ } @empty {
261
+ <li>No safety event recorded.</li>
262
+ }
263
+ </ul>
264
+ @if (!safetyExhausted()) {
265
+ <button matButton type="button" [disabled]="busy()" (click)="loadSafetyEvents()">Load more</button>
266
+ }
267
+ }
268
+ }
235
269
  @default {
236
270
  @if (productTab(); as t) {
237
271
  @if (selected(); as agent) {
@@ -13,8 +13,9 @@ import { BaseAuthService } from '../../service/auth.service';
13
13
  import { CaptionPipe } from '../../service/caption_pipe';
14
14
  import { LabelOption, LabelService } from '../../service/label.service';
15
15
  import {
16
- AgentAuditEvent, AgentDraft, AgentLanguageDraft, AgentMergeMode, AgentModelSelection, AgentRelease, AgentSetting,
17
- AgentSettings, AgentSummary, AgentTestResult, StudioFeature, StudioModel,
16
+ AGENT_READINESS_CAPTIONS, AgentAuditEvent, AgentReadiness, AgentDraft, AgentGuardrails, AgentLanguageDraft, AgentMergeMode,
17
+ AgentModelSelection, AgentRelease, AgentSetting, AgentSettings, AgentSummary, AgentTestResult, GuardrailRule,
18
+ SafetyEvent, StudioFeature, StudioModel,
18
19
  } from '../model/agent_studio';
19
20
  import { AgentStudioService } from '../service/agent_studio.service';
20
21
  import { ReleaseSectionDiff, diffReleaseSections } from '../util/release_diff';
@@ -29,11 +30,12 @@ const CORE_TABS = [
29
30
  { id: 'test', label: 'Test' },
30
31
  { id: 'history', label: 'History' },
31
32
  ];
33
+ const GUARDRAILS_TAB = { id: 'guardrails', label: 'Guardrails' };
32
34
 
33
35
  /**
34
36
  * Embeddable Agent Studio over Scout's core `agents` feature: a grouped agent rail and tabs
35
- * for the draft, prompts, scoped settings, tests, and release history, plus product tabs
36
- * declared with `sailStudioTab`. A control shows only when the feature manifest lists its
37
+ * for the draft, prompts, scoped settings, tests, and release history, a Guardrails tab when the
38
+ * manifest lists that feature, plus product tabs declared with `sailStudioTab`. A control shows only when the feature manifest lists its
37
39
  * action and the caller holds the grant; hiding is never the authorization, Scout decides.
38
40
  * Selector: <sail-studio-page>.
39
41
  */
@@ -66,6 +68,9 @@ export class StudioPageComponent implements OnInit {
66
68
  readonly settings = signal<AgentSettings | null>(null);
67
69
  readonly releases = signal<AgentRelease[]>([]);
68
70
  readonly audit = signal<AgentAuditEvent[]>([]);
71
+ readonly guardrails = signal<AgentGuardrails | null>(null);
72
+ readonly safetyEvents = signal<SafetyEvent[]>([]);
73
+ readonly safetyExhausted = signal(true);
69
74
  readonly diff = signal<{ version: number; previous: number; rows: ReleaseSectionDiff[] } | null>(null);
70
75
  readonly testResult = signal<AgentTestResult | null>(null);
71
76
  readonly tab = signal('overview');
@@ -79,7 +84,10 @@ export class StudioPageComponent implements OnInit {
79
84
  private readonly pending = signal(0);
80
85
  readonly busy = computed(() => this.pending() > 0);
81
86
 
82
- readonly tabs = computed(() => [...CORE_TABS, ...this.productTabs().map((t) => ({ id: t.id(), label: t.label() }))]);
87
+ readonly tabs = computed(() => [
88
+ ...CORE_TABS, ...(this.feature('guardrails') ? [GUARDRAILS_TAB] : []),
89
+ ...this.productTabs().map((t) => ({ id: t.id(), label: t.label() })),
90
+ ]);
83
91
  readonly productTab = computed(() => this.productTabs().find((t) => t.id() === this.tab()));
84
92
  readonly selected = computed(() => this.summaries().find((a) => a.agent_name === this.selectedName()));
85
93
  readonly dirty = computed(() => this.draft() !== null && JSON.stringify(this.draft()) !== this.baseline());
@@ -97,7 +105,6 @@ export class StudioPageComponent implements OnInit {
97
105
  }
98
106
  return [...groups].map(([name, agents]) => ({ name, agents }));
99
107
  });
100
- private readonly core = computed(() => this.features().find((f) => f.id === 'agents'));
101
108
 
102
109
  ngOnInit(): void {
103
110
  this.call(this.studio.features(), (features) => this.features.set(features));
@@ -110,10 +117,18 @@ export class StudioPageComponent implements OnInit {
110
117
  return this.dirty();
111
118
  }
112
119
 
113
- /** Whether the manifest serves `action` and the caller holds it at `value` (the selected agent by default). */
114
- can(action: string, value = `agent:${this.selectedName()}`): boolean {
115
- const core = this.core();
116
- return !!core?.actions.includes(action) && this.auth.hasPermission(core.object || 'AGENT_STUDIO', action, value);
120
+ /** Whether `feature` serves `action` and the caller holds it at `value` (the selected agent by default). */
121
+ can(action: string, value = `agent:${this.selectedName()}`, feature = 'agents'): boolean {
122
+ const f = this.feature(feature);
123
+ return !!f?.actions.includes(action) && this.auth.hasPermission(f.object || 'AGENT_STUDIO', action, value);
124
+ }
125
+
126
+ private feature(id: string): StudioFeature | undefined {
127
+ return this.features().find((f) => f.id === id);
128
+ }
129
+
130
+ readinessCaption(code: AgentReadiness): string {
131
+ return AGENT_READINESS_CAPTIONS[code] ?? code;
117
132
  }
118
133
 
119
134
  modelsOf(type: string): StudioModel[] {
@@ -136,6 +151,8 @@ export class StudioPageComponent implements OnInit {
136
151
  this.settings.set(null);
137
152
  this.releases.set([]);
138
153
  this.audit.set([]);
154
+ this.guardrails.set(null);
155
+ this.safetyEvents.set([]);
139
156
  this.diff.set(null);
140
157
  this.testResult.set(null);
141
158
  this.errorMessage.set('');
@@ -146,6 +163,7 @@ export class StudioPageComponent implements OnInit {
146
163
  openTab(id: string): void {
147
164
  this.tab.set(id);
148
165
  if (id === 'history') this.loadHistory();
166
+ if (id === 'guardrails') this.loadGuardrails();
149
167
  }
150
168
 
151
169
  private loadAgent(): void {
@@ -153,6 +171,7 @@ export class StudioPageComponent implements OnInit {
153
171
  this.call(this.studio.getDraft(name), (draft) => this.applyDraft(draft), name);
154
172
  this.call(this.studio.settings(name), (settings) => this.settings.set(settings), name);
155
173
  if (this.tab() === 'history') this.loadHistory();
174
+ if (this.tab() === 'guardrails') this.loadGuardrails();
156
175
  }
157
176
 
158
177
  private applyDraft(draft: AgentDraft): void {
@@ -312,6 +331,44 @@ export class StudioPageComponent implements OnInit {
312
331
  }, name);
313
332
  }
314
333
 
334
+ loadGuardrails(): void {
335
+ const name = this.selectedName();
336
+ this.safetyEvents.set([]);
337
+ this.call(this.studio.guardrails(name), (g) => this.guardrails.set(g), name);
338
+ this.loadSafetyEvents();
339
+ }
340
+
341
+ loadSafetyEvents(): void {
342
+ const name = this.selectedName();
343
+ const before = this.safetyEvents().at(-1)?.id ?? 0;
344
+ this.call(this.studio.safetyEvents(name, before), (events) => {
345
+ this.safetyEvents.update((rows) => [...rows, ...events]);
346
+ this.safetyExhausted.set(events.length < 50);
347
+ }, name);
348
+ }
349
+
350
+ /** Replaces the agent's own rules with the JSON array typed in. */
351
+ saveGuardrails(rulesText: string): void {
352
+ const g = this.guardrails();
353
+ if (!g) return;
354
+ let rules: GuardrailRule[];
355
+ try {
356
+ rules = JSON.parse(rulesText.trim() || '[]') as GuardrailRule[];
357
+ } catch {
358
+ this.errorMessage.set('The rules must be a JSON array.');
359
+ return;
360
+ }
361
+ if (!Array.isArray(rules)) {
362
+ this.errorMessage.set('The rules must be a JSON array.');
363
+ return;
364
+ }
365
+ this.act(this.studio.saveGuardrails({ agent_name: g.agent_name, rules, expected_agent_revision: g.expected_agent_revision }), (saved) => {
366
+ this.guardrails.set(saved);
367
+ this.applyRevisions(saved.expected_agent_revision);
368
+ this.successMessage.set('Guardrails saved.');
369
+ }, g.agent_name);
370
+ }
371
+
315
372
  previousVersion(version: number): number {
316
373
  return this.releases().reduce((best, r) => r.version < version && r.version > best ? r.version : best, 0);
317
374
  }
@@ -0,0 +1,27 @@
1
+ <div class="turn-thread">
2
+ @for (turn of turns(); track turn.request_id) {
3
+ <article [class]="'turn-thread__turn turn-thread__turn--' + turn.status">
4
+ <header class="turn-thread__head">
5
+ <span class="turn-thread__title">{{ label(turn.task_kind) }} · {{ turn.input_summary }}</span>
6
+ <span class="turn-thread__status">{{ turn.status | sailCaption:'turn_status' }}</span>
7
+ </header>
8
+ <p class="turn-thread__meta">
9
+ {{ turn.created_at | date: 'medium' }}
10
+ @if (turn.cost_minor !== undefined && turn.currency && turn.exponent !== undefined) { · {{ turn.cost_minor | sailMoney:turn.currency:turn.exponent }} }
11
+ @if (turn.release_version) { · {{ turn.agent_name }} v{{ turn.release_version }} }
12
+ </p>
13
+ @if (turn.status === 'F') {
14
+ <p class="turn-thread__error" role="alert">{{ turn.error_text || failedText() }}</p>
15
+ }
16
+ @if (resultTemplate(turn.task_kind); as template) {
17
+ <ng-container [ngTemplateOutlet]="template" [ngTemplateOutletContext]="{ $implicit: turn }" />
18
+ }
19
+ </article>
20
+ } @empty {
21
+ @if (!loading()) { <p class="turn-thread__empty">{{ emptyMessage() }}</p> }
22
+ }
23
+ @if (nextBefore()) {
24
+ <button matButton type="button" [disabled]="loading()" (click)="more()">Load more</button>
25
+ }
26
+ @if (errorMessage()) { <p class="turn-thread__error" role="alert">{{ errorMessage() }}</p> }
27
+ </div>
@@ -0,0 +1,94 @@
1
+ import { DatePipe, NgTemplateOutlet } from '@angular/common';
2
+ import {
3
+ ChangeDetectionStrategy, Component, DestroyRef, Directive, OnInit, TemplateRef, contentChildren, inject, input, signal,
4
+ } from '@angular/core';
5
+ import { MatButtonModule } from '@angular/material/button';
6
+ import { BaseAsync } from '../../component/abstract/base_async';
7
+ import { CaptionPipe } from '../../service/caption_pipe';
8
+ import { MoneyPipe } from '../../service/money_pipe';
9
+ import { ConversationTurn, TERMINAL_TURN_STATUSES } from '../model/conversation';
10
+ import { ConversationService } from '../service/conversation.service';
11
+
12
+ export interface TurnResultContext {
13
+ $implicit: ConversationTurn;
14
+ }
15
+
16
+ /**
17
+ * The result card of one task kind, or of every other kind with `"*"`:
18
+ * `<ng-template sailTurnResult="kind" let-turn>…</ng-template>`.
19
+ */
20
+ @Directive({ selector: 'ng-template[sailTurnResult]' })
21
+ export class TurnResultDirective {
22
+ readonly kind = input.required<string>({ alias: 'sailTurnResult' });
23
+ readonly template = inject<TemplateRef<TurnResultContext>>(TemplateRef);
24
+
25
+ static ngTemplateContextGuard(_dir: TurnResultDirective, ctx: unknown): ctx is TurnResultContext {
26
+ return true;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * The signed-in person's turns from `GET {base}turns`, newest first, with status captions, cost
32
+ * and release version, paged by `next_before` and reloaded every `pollMs` while a turn is
33
+ * nonterminal. Selector: <sail-turn-thread>.
34
+ */
35
+ @Component({
36
+ selector: 'sail-turn-thread',
37
+ changeDetection: ChangeDetectionStrategy.OnPush,
38
+ imports: [DatePipe, NgTemplateOutlet, MatButtonModule, CaptionPipe, MoneyPipe],
39
+ templateUrl: './turn_thread.html',
40
+ })
41
+ export class TurnThreadComponent extends BaseAsync implements OnInit {
42
+ private readonly conversation = inject(ConversationService);
43
+
44
+ /** Only turns that served this work item; all of the person's turns when empty. */
45
+ readonly workRef = input('');
46
+ /** `task_kind` → label; the kind itself otherwise. */
47
+ readonly kindLabels = input<Record<string, string>>({});
48
+ readonly pollMs = input(10000);
49
+ readonly emptyMessage = input('Nothing yet.');
50
+ /** Shown for a failed turn that carries no error text. */
51
+ readonly failedText = input('The turn failed.');
52
+
53
+ readonly turns = signal<ConversationTurn[]>([]);
54
+ readonly nextBefore = signal('');
55
+ private readonly results = contentChildren(TurnResultDirective);
56
+ private timer: ReturnType<typeof setTimeout> | undefined;
57
+
58
+ constructor() {
59
+ super();
60
+ inject(DestroyRef).onDestroy(() => clearTimeout(this.timer));
61
+ }
62
+
63
+ ngOnInit(): void {
64
+ this.load();
65
+ }
66
+
67
+ /** Reloads the first page; the pages below it are dropped. */
68
+ load(): void {
69
+ clearTimeout(this.timer);
70
+ this.run(this.conversation.turns(this.workRef()), (page) => {
71
+ this.turns.set(page.turns);
72
+ this.nextBefore.set(page.next_before ?? '');
73
+ if (page.turns.some((t) => !TERMINAL_TURN_STATUSES.has(t.status))) {
74
+ this.timer = setTimeout(() => this.load(), Math.max(1000, this.pollMs()));
75
+ }
76
+ }, 'Failed to load the turns.');
77
+ }
78
+
79
+ more(): void {
80
+ this.run(this.conversation.turns(this.workRef(), this.nextBefore()), (page) => {
81
+ this.turns.update((rows) => [...rows, ...page.turns]);
82
+ this.nextBefore.set(page.next_before ?? '');
83
+ }, 'Failed to load more turns.');
84
+ }
85
+
86
+ label(kind: string): string {
87
+ return this.kindLabels()[kind] ?? kind;
88
+ }
89
+
90
+ resultTemplate(kind: string): TemplateRef<TurnResultContext> | undefined {
91
+ const results = this.results();
92
+ return (results.find((r) => r.kind() === kind) ?? results.find((r) => r.kind() === '*'))?.template;
93
+ }
94
+ }
@@ -21,3 +21,5 @@ export * from './component/decision_timeline';
21
21
  export * from './component/studio_tab';
22
22
  export * from './component/studio_page';
23
23
  export * from './component/approval_inbox';
24
+ export * from './component/assistant_activity';
25
+ export * from './component/turn_thread';
@@ -12,7 +12,7 @@ export interface AgentSummary {
12
12
  enabled: boolean;
13
13
  is_default: boolean;
14
14
  archived: boolean;
15
- readiness: string;
15
+ readiness: AgentReadiness;
16
16
  readiness_reason: string;
17
17
  type_defaults_revision: number;
18
18
  agent_revision: number;
@@ -309,3 +309,69 @@ export interface AgentSettingChange {
309
309
  expected_agent_revision: number;
310
310
  expected_type_defaults_revision: number;
311
311
  }
312
+
313
+ /** Scout's fixed `AgentReadiness` values, which no constant domain carries. */
314
+ export type AgentReadiness =
315
+ 'ready' | 'disabled' | 'missing_model' | 'unpublished' | 'error' | 'not_entitled' | 'needs_integration' | 'forbidden';
316
+
317
+ export const AGENT_READINESS_CAPTIONS: Record<AgentReadiness, string> = {
318
+ ready: 'Ready',
319
+ disabled: 'Disabled',
320
+ missing_model: 'No text model',
321
+ unpublished: 'Unpublished',
322
+ error: 'Invalid definition',
323
+ not_entitled: 'Not entitled',
324
+ needs_integration: 'Needs integration',
325
+ forbidden: 'Forbidden',
326
+ };
327
+
328
+ /** One enforced guardrail rule; `params` is the kind's JSON parameters. */
329
+ export interface GuardrailRule {
330
+ id: string;
331
+ kind: string;
332
+ stages?: string[];
333
+ action: string;
334
+ severity: string;
335
+ params?: unknown;
336
+ }
337
+
338
+ /** Rules an agent inherits from one layer: B baseline, P platform, T tenant, Y type. */
339
+ export interface InheritedGuardrails {
340
+ layer: string;
341
+ digest?: string;
342
+ rules: GuardrailRule[];
343
+ }
344
+
345
+ /** The layers an agent inherits, widest first, and its own rules, which only add or tighten. */
346
+ export interface AgentGuardrails {
347
+ agent_name: string;
348
+ inherited: InheritedGuardrails[];
349
+ rules: GuardrailRule[];
350
+ /** Why the agent's rules no longer fit the inherited ones. */
351
+ problem?: string;
352
+ expected_agent_revision: number;
353
+ }
354
+
355
+ /** Replaces the agent's own rules as a whole; an empty list removes them. */
356
+ export interface AgentGuardrailChange {
357
+ agent_name: string;
358
+ rules: GuardrailRule[];
359
+ expected_agent_revision: number;
360
+ }
361
+
362
+ /** One stored guardrail hit: rule ids and attribution, never the inspected content. */
363
+ export interface SafetyEvent {
364
+ id: number;
365
+ version?: string;
366
+ policy_version?: string;
367
+ stage: string;
368
+ layer: string;
369
+ action: string;
370
+ severity: string;
371
+ rule_ids: string[];
372
+ actor_kind?: string;
373
+ actor_id?: string;
374
+ request_id?: string;
375
+ duration_ms: number;
376
+ occurred_at: string;
377
+ }
@@ -1,4 +1,6 @@
1
- /** Scout approval desk wire types (v1.2.7). Codes render through backend captions. */
1
+ import { PrincipalRef } from './decision';
2
+
3
+ /** Scout approval desk and MCP confirmation inbox wire types (v1.2.14). Codes render through backend captions. */
2
4
 
3
5
  /** A approved, X rejected, E edited (tool-call proposals only, with revised arguments). */
4
6
  export type ApprovalVerdict = 'A' | 'X' | 'E';
@@ -40,3 +42,45 @@ export interface ApprovalResolveRequest {
40
42
  /** The JSON object an `E` verdict runs the tool call with instead. */
41
43
  revised_arguments?: Record<string, unknown>;
42
44
  }
45
+
46
+ /** `mcp_confirmation_status`: P pending, A approved, R running, D executed, F failed, U unknown, X declined, W withdrawn, T expired. */
47
+ export type ConfirmationStatus = 'P' | 'A' | 'R' | 'D' | 'F' | 'U' | 'X' | 'W' | 'T';
48
+
49
+ /** A verified reconciliation outcome. */
50
+ export type ReconcileOutcome = Extract<ConfirmationStatus, 'D' | 'F'>;
51
+
52
+ /** Scout's bound on decision and reconciliation notes, in characters. */
53
+ export const CONFIRMATION_NOTE_MAX = 500;
54
+
55
+ export interface ConfirmationKey {
56
+ tenant_id: number;
57
+ id: number;
58
+ }
59
+
60
+ /** One MCP tool call awaiting or past a person's confirmation; never its payload. */
61
+ export interface Confirmation extends ConfirmationKey {
62
+ tenant_label?: string;
63
+ maker: PrincipalRef;
64
+ client_ref?: string;
65
+ client_label?: string;
66
+ tool: string;
67
+ payload_digest: string;
68
+ /** The product's preview of the complete action. */
69
+ preview?: unknown;
70
+ channel: string;
71
+ status: ConfirmationStatus;
72
+ approval_required: boolean;
73
+ expired: boolean;
74
+ expires_at: string;
75
+ created_at: string;
76
+ decided_at?: string;
77
+ decider?: PrincipalRef;
78
+ decision_note?: string;
79
+ error?: string;
80
+ completed_at?: string;
81
+ reconciler?: PrincipalRef;
82
+ reconcile_note?: string;
83
+ can_decide: boolean;
84
+ can_withdraw: boolean;
85
+ can_reconcile: boolean;
86
+ }
@@ -36,7 +36,41 @@ export class ConversationStreamFailure extends Error {
36
36
  }
37
37
  }
38
38
 
39
- /** Where the app mounts Scout's ConversationHandler, e.g. '/api/wingmate/'. */
39
+ /** A `turn_status` code; C, F and X are terminal, as Scout's catalog marks them. */
40
+ export type TurnStatus = 'Q' | 'R' | 'S' | 'U' | 'H' | 'O' | 'C' | 'F' | 'X';
41
+
42
+ export const TERMINAL_TURN_STATUSES: ReadonlySet<string> = new Set<TurnStatus>(['C', 'F', 'X']);
43
+
44
+ /** One turn of the signed-in person's history; cost fields are absent while nothing is settled. */
45
+ export interface ConversationTurn {
46
+ request_id: string;
47
+ conversation_id: string;
48
+ agent_name: string;
49
+ release_version: string;
50
+ status: TurnStatus;
51
+ work_ref?: string;
52
+ task_kind: string;
53
+ input_summary: string;
54
+ error_text?: string;
55
+ /** The product's result card, handed to the kind's `sailTurnResult` template. */
56
+ result?: unknown;
57
+ created_at: string;
58
+ completed_at?: string;
59
+ cost_minor?: number;
60
+ currency?: string;
61
+ exponent?: number;
62
+ }
63
+
64
+ /** Newest first; `next_before` is absent on the last page. */
65
+ export interface ConversationTurnPage {
66
+ turns: ConversationTurn[];
67
+ next_before?: string;
68
+ }
69
+
70
+ /**
71
+ * Where the app mounts Scout's ConversationHandler, e.g. '/api/wingmate/'. `ConversationService` is a
72
+ * root singleton: provide this at bootstrap, or provide both together on a component.
73
+ */
40
74
  export const CONVERSATION_BASE_PATH = new InjectionToken<string>('SailConversationBasePath', {
41
75
  providedIn: 'root',
42
76
  factory: () => '/api/conversation/',
@@ -1,11 +1,11 @@
1
- import { ObjectRef } from './turn_event';
2
-
3
1
  /** A allowed, D denied, P pending (`audit_decision_outcome`). */
4
2
  export type DecisionOutcome = 'A' | 'D' | 'P';
5
3
 
6
4
  export interface PrincipalRef {
7
5
  kind: string;
8
6
  id: string;
7
+ /** The product's caption for the principal (Scout `contract.Labeler`). */
8
+ label?: string;
9
9
  }
10
10
 
11
11
  export interface AuthorityRef {
@@ -14,12 +14,9 @@ export interface AuthorityRef {
14
14
  grantor?: PrincipalRef;
15
15
  }
16
16
 
17
- /**
18
- * One audit record as sail renders it. The audit endpoint is the app's, so the app maps its
19
- * response onto this shape; `id` is whatever key the endpoint gives a record.
20
- */
17
+ /** One governed decision of the tenant (Scout `api.DecisionRecord`); evidence is a digest only. */
21
18
  export interface DecisionRecord {
22
- id: string | number;
19
+ id: number;
23
20
  category: string;
24
21
  action: string;
25
22
  resource: string;
@@ -27,18 +24,33 @@ export interface DecisionRecord {
27
24
  reason: string;
28
25
  principal: PrincipalRef;
29
26
  authority?: AuthorityRef;
27
+ /** The client application the principal acted through. */
28
+ client_ref?: string;
29
+ client_label?: string;
30
+ work_ref?: string;
30
31
  scope_id?: string;
31
32
  release_version?: string;
32
33
  policy_id?: string;
33
34
  policy_version?: string;
34
35
  obligations?: string[];
35
- evidence?: ObjectRef;
36
+ evidence_digest?: string;
36
37
  request_id: string;
37
38
  conversation_id?: string;
38
39
  occurred_at: string;
39
40
  }
40
41
 
42
+ /** Newest first; `next_before` is absent on the last page. */
43
+ export interface DecisionPage {
44
+ records: DecisionRecord[];
45
+ next_before?: number;
46
+ }
47
+
41
48
  export interface DecisionCategoryGroup {
42
49
  category: string;
43
50
  records: DecisionRecord[];
44
51
  }
52
+
53
+ /** A principal's label, or its kind and id. */
54
+ export function principalName(ref: PrincipalRef): string {
55
+ return ref.label || `${ref.kind} ${ref.id}`;
56
+ }
@@ -3,6 +3,8 @@ import { Observable } from 'rxjs';
3
3
  import { BaseRestService } from '../../service/base_rest.service';
4
4
  import {
5
5
  AgentArchiveRequest,
6
+ AgentGuardrailChange,
7
+ AgentGuardrails,
6
8
  AgentAuditEvent,
7
9
  AgentCreateRequest,
8
10
  AgentDraft,
@@ -20,9 +22,11 @@ import {
20
22
  AgentSummary,
21
23
  AgentTestRequest,
22
24
  AgentTestResult,
25
+ SafetyEvent,
23
26
  StudioFeature,
24
27
  StudioModel,
25
28
  } from '../model/agent_studio';
29
+ import { DecisionOutcome, DecisionPage } from '../model/decision';
26
30
 
27
31
  const studioBasePath = '/api/agent-studio/';
28
32
 
@@ -114,6 +118,31 @@ export class AgentStudioService extends BaseRestService {
114
118
  return this.http.get<StudioModel[]>(this.studioUrl('models'));
115
119
  }
116
120
 
121
+ /** Scout's optional `decisions` feature: the tenant's decision records, newest first. */
122
+ decisions(before = 0, limit = 0, category = '', outcome?: DecisionOutcome): Observable<DecisionPage> {
123
+ const params: Record<string, string> = {};
124
+ if (before) params['before'] = String(before);
125
+ if (limit) params['limit'] = String(limit);
126
+ if (category) params['category'] = category;
127
+ if (outcome) params['outcome'] = outcome;
128
+ return this.http.get<DecisionPage>(this.studioUrl('decisions'), { params });
129
+ }
130
+
131
+ /** Scout's optional `guardrails` feature. */
132
+ guardrails(agentName: string): Observable<AgentGuardrails> {
133
+ return this.http.get<AgentGuardrails>(this.studioUrl('guardrails'), { params: { agent_name: agentName } });
134
+ }
135
+
136
+ saveGuardrails(change: AgentGuardrailChange): Observable<AgentGuardrails> {
137
+ return this.http.post<AgentGuardrails>(this.studioUrl('guardrails'), change);
138
+ }
139
+
140
+ /** Newest first; pass the last id as `before` for the next page. */
141
+ safetyEvents(agentName: string, before = 0, limit = 50): Observable<SafetyEvent[]> {
142
+ return this.http.get<SafetyEvent[]>(this.studioUrl('safety-events'),
143
+ { params: { agent_name: agentName, before: String(before), limit: String(limit) } });
144
+ }
145
+
117
146
  private studioUrl(route: string): string {
118
147
  return this.url(studioBasePath + route);
119
148
  }
@@ -1,18 +1,38 @@
1
1
  import { Injectable } from '@angular/core';
2
2
  import { Observable } from 'rxjs';
3
3
  import { BaseRestService } from '../../service/base_rest.service';
4
- import { ApprovalRequest, ApprovalResolveRequest } from '../model/approval';
4
+ import { ApprovalRequest, ApprovalResolveRequest, Confirmation, ConfirmationKey, ReconcileOutcome } from '../model/approval';
5
5
 
6
- /** Client for Scout's approval desk under the fixed `/api/approval/` path. */
6
+ const base = '/api/approval/';
7
+
8
+ /** Client for Scout's approval desk and MCP confirmation inbox under the fixed `/api/approval/` path. */
7
9
  @Injectable({ providedIn: 'root' })
8
10
  export class ApprovalService extends BaseRestService {
9
11
  /** The requests the signed-in person may decide now, soonest deadline first. */
10
12
  pending(): Observable<ApprovalRequest[]> {
11
- return this.http.get<ApprovalRequest[]>(this.url('/api/approval/pending'));
13
+ return this.http.get<ApprovalRequest[]>(this.url(base + 'pending'));
12
14
  }
13
15
 
14
16
  /** 409 when the request was reassigned or decided differently since it was read. */
15
17
  resolve(request: ApprovalResolveRequest): Observable<ApprovalRequest> {
16
- return this.http.post<ApprovalRequest>(this.url('/api/approval/resolve'), request);
18
+ return this.http.post<ApprovalRequest>(this.url(base + 'resolve'), request);
19
+ }
20
+
21
+ /** Open confirmations and those created since `since` (RFC 3339), across the person's tenants. */
22
+ confirmations(since = ''): Observable<Confirmation[]> {
23
+ return this.http.get<Confirmation[]>(this.url(base + 'confirmations'), { params: since ? { since } : {} });
24
+ }
25
+
26
+ decideConfirmation(key: ConfirmationKey, approve: boolean, note = ''): Observable<Confirmation> {
27
+ return this.http.post<Confirmation>(this.url(base + 'confirmation/decide'), { ...key, approve, note });
28
+ }
29
+
30
+ withdrawConfirmation(key: ConfirmationKey): Observable<ConfirmationKey> {
31
+ return this.http.post<ConfirmationKey>(this.url(base + 'confirmation/withdraw'), key);
32
+ }
33
+
34
+ /** Records the verified outcome of an unknown confirmation; the note is required. */
35
+ reconcileConfirmation(key: ConfirmationKey, outcome: ReconcileOutcome, note: string): Observable<Confirmation> {
36
+ return this.http.post<Confirmation>(this.url(base + 'confirmation/reconcile'), { ...key, outcome, note });
17
37
  }
18
38
  }