Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,27 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
reasons that say nothing about the model, so folding it in would measure the
gate instead of the answer.

- A forced source a queued run asked for and did not get is now recorded on the
run (ADR-179).

Switching a snippet or skill off between enqueue and start drops it — that is
ADR-175's rule and stays — but until now nothing said so, and the only way to
notice was to compare the queued request against the transcript.

The run now carries a `dropped` step naming each source and why: `deactivated`
for a record that is switched off, `gone` for one that no longer resolves.
Those stay apart on purpose — a deactivated record can be switched back on, a
removed one cannot, so a single "dropped" would send the reader looking.

A run that dropped nothing records no step, so the step's presence is the
signal. The resume path is untouched: ADR-166 and ADR-175 keep a deactivated
source resolving there, so nothing is dropped and a report would imply
otherwise.

`RunAugmentation` gains a `droppedSources` parameter, appended with a default;
`RunTrace` gains `recordDroppedSources()`. Both are additive.


### Changed
- **AGENTS.md files synchronized with the repository state.** Root slimmed from 356 to 119 lines by moving content into the scoped files it belongs to; stale inventories refreshed (TCA files, database tables, backend templates, JS modules, `Services.Dashboard.php`); phantom `TCA/Overrides/` and dead `MEMORY.md` references removed; generic workflow boilerplate in `.github/workflows/AGENTS.md` replaced with this repository's actual conventions (no local jobs, release flow, dependency automation).

Expand Down
42 changes: 42 additions & 0 deletions Classes/Domain/Enum/DroppedSourceReason.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

/*
* Copyright (c) 2025-2026 Netresearch DTT GmbH
* SPDX-License-Identifier: GPL-2.0-or-later
*/

declare(strict_types=1);

namespace Netresearch\NrLlm\Domain\Enum;

/**
* Why a forced source the run asked for did not arrive (ADR-179).
*
* The two cases are kept apart deliberately. Both resolve to nothing, so a
* single "dropped" would be easier to produce — and it would flatten two
* different operator actions with different remedies: a deactivated record can
* be switched back on, a removed one cannot. A reader who cannot tell them
* apart has to go looking, which is the situation this record exists to end.
*
* @internal Not part of the @api surface; may change without notice (ADR-127).
*/
enum DroppedSourceReason: string
{
/**
* The record exists and is switched off.
*
* Detected by resolving the same uid twice: the existence lookup finds it,
* the enabled-only lookup does not. That difference is the whole signal —
* without both lookups the two reasons are indistinguishable.
*/
case DEACTIVATED = 'deactivated';

/**
* Nothing with that uid resolves at all.
*
* Covers a deleted record and a uid that never existed. Those are one case
* from the run's side: it asked for something it did not get, and no record
* remains to say which of the two it was.
*/
case GONE = 'gone';
}
30 changes: 30 additions & 0 deletions Classes/Domain/ValueObject/DroppedSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

/*
* Copyright (c) 2025-2026 Netresearch DTT GmbH
* SPDX-License-Identifier: GPL-2.0-or-later
*/

declare(strict_types=1);

namespace Netresearch\NrLlm\Domain\ValueObject;

use Netresearch\NrLlm\Domain\Enum\DroppedSourceReason;

/**
* One forced source a run asked for and did not get (ADR-179).
*
* Carries the uid rather than the record, because there is no record to carry:
* that is the event. The kind is a plain string ('skill' / 'snippet') so this
* object does not need to know about either repository.
*
* @internal Not part of the @api surface; may change without notice (ADR-127).
*/
final readonly class DroppedSource
{
public function __construct(
public string $kind,
public int $uid,
public DroppedSourceReason $reason,
) {}
}
23 changes: 23 additions & 0 deletions Classes/Domain/ValueObject/RunStep.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@

public const KIND_CONTEXT = 'context';

/**
* Forced sources the run asked for and did not get (ADR-179).
*
* Written once, before the first round, and only when something was
* actually dropped — a run whose sources all resolved records no step, so
* the step's presence is itself the signal.
*/
public const KIND_DROPPED = 'dropped';

/**
* @param list<array<string, mixed>>|null $messagesSent Snapshot of the messages sent this round (REQUEST/assembled).
* @param list<string>|null $toolSpecs Names of the tools offered this round (REQUEST).
Expand Down Expand Up @@ -78,6 +87,11 @@ public function __construct(
public ?bool $toolIsError = null,
public ?array $toolArtifacts = null,
public ?ContextBudgetBreakdown $contextBudget = null,
/**
* @var list<DroppedSource>|null the sources this run asked for and did
* not get; null on every other kind
*/
public ?array $droppedSources = null,
) {}

/**
Expand Down Expand Up @@ -106,6 +120,15 @@ public function toArray(): array
'estimatedCost' => $this->estimatedCost,
'requestedToolCalls' => $this->requestedToolCalls,
'raw' => $this->raw,
// Flattened to "kind#uid reason" strings rather than nested objects:
// the timeline's allow-list renders scalars and simple lists, and a
// count alone would flatten the two reasons ADR-179 keeps apart.
// uid and reason are metadata, not content — the privacy filter's
// concern is the transcript, and nothing here carries prose.
'droppedSources' => $this->droppedSources === null ? null : array_map(
static fn(DroppedSource $d): string => sprintf('%s#%d %s', $d->kind, $d->uid, $d->reason->value),
$this->droppedSources,
),
'toolName' => $this->toolName,
'toolArguments' => $this->toolArguments,
'toolResult' => $this->toolResult,
Expand Down
103 changes: 101 additions & 2 deletions Classes/Service/Agent/AgentRunRequestCodec.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

namespace Netresearch\NrLlm\Service\Agent;

use Netresearch\NrLlm\Domain\Enum\DroppedSourceReason;
use Netresearch\NrLlm\Domain\Model\PromptSnippet;
use Netresearch\NrLlm\Domain\Model\Skill;
use Netresearch\NrLlm\Domain\Repository\LlmConfigurationRepository;
Expand All @@ -17,6 +18,7 @@
use Netresearch\NrLlm\Domain\ValueObject\AgentRun;
use Netresearch\NrLlm\Domain\ValueObject\AiActorContext;
use Netresearch\NrLlm\Domain\ValueObject\ChatMessage;
use Netresearch\NrLlm\Domain\ValueObject\DroppedSource;
use Netresearch\NrLlm\Service\Agent\Exception\RunConfigurationGoneException;
use Netresearch\NrLlm\Service\Option\ToolOptions;
use Netresearch\NrLlm\Service\Tool\RunAugmentation;
Expand Down Expand Up @@ -154,10 +156,15 @@ public function rehydrate(AgentRun $run): AgentRunRequest
$augmentation = null;
if (is_array($data['augmentation'] ?? null)) {
$augmentationData = $data['augmentation'];
$skillUids = $this->uidList($augmentationData['forcedSkillUids'] ?? null);
$snippetUids = $this->uidList($augmentationData['forcedSnippetUids'] ?? null);
$forcedSkills = $this->skillsByUids($skillUids);
$forcedSnippets = $this->snippetsByUids($snippetUids);
$augmentation = new RunAugmentation(
forcedSkills: $this->skillsByUids($this->uidList($augmentationData['forcedSkillUids'] ?? null)),
forcedSnippets: $this->snippetsByUids($this->uidList($augmentationData['forcedSnippetUids'] ?? null)),
forcedSkills: $forcedSkills,
forcedSnippets: $forcedSnippets,
dryRun: ($augmentationData['dryRun'] ?? false) === true,
droppedSources: $this->droppedSources($skillUids, $forcedSkills, $snippetUids, $forcedSnippets),
);
}

Expand Down Expand Up @@ -241,4 +248,96 @@ private function snippetsByUids(array $uids): array

return $this->promptSnippetRepository->findByUids($uids);
}

/**
* The forced sources this run asked for and did not get (ADR-179).
*
* Resolves each kind a SECOND time through its existence lookup. That is
* the only way to tell the two reasons apart: a uid the enabled-only
* lookup skipped but the existence lookup finds is switched off; one
* neither finds is gone. Without the second call both look identical, and
* a reader would be told "dropped" without being told what to do about it.
*
* ONE extra query per kind, not two: the records that DID arrive are passed
* in, because the rehydration has just resolved them. Re-querying them here
* would double every enabled-only lookup on the dequeue path — which is
* what `ToolLoopServiceAssemblyOrderTest` pins, and it caught exactly that
* in the first version of this method.
*
* @param list<int> $skillUids uids the run was queued with
* @param list<Skill> $arrivedSkills what the enabled-only lookup returned
* @param list<int> $snippetUids
* @param list<PromptSnippet> $arrivedSnippets
*
* @return list<DroppedSource>
*/
private function droppedSources(array $skillUids, array $arrivedSkills, array $snippetUids, array $arrivedSnippets): array
{
$dropped = [];

if ($skillUids !== [] && $this->skillRepository instanceof SkillRepository) {
$dropped = [...$dropped, ...$this->missing(
'skill',
$skillUids,
$this->uidsOf($arrivedSkills),
$this->uidsOf($this->skillRepository->findExistingByUids($skillUids)),
)];
}

if ($snippetUids !== [] && $this->promptSnippetRepository instanceof PromptSnippetRepository) {
return [...$dropped, ...$this->missing(
'snippet',
$snippetUids,
$this->uidsOf($arrivedSnippets),
$this->uidsOf($this->promptSnippetRepository->findExistingByUids($snippetUids)),
)];
}

return $dropped;
}

/**
* @param list<int> $requested
* @param list<int> $arrived uids the enabled-only lookup returned
* @param list<int> $existing uids the existence lookup returned
*
* @return list<DroppedSource>
*/
private function missing(string $kind, array $requested, array $arrived, array $existing): array
{
$dropped = [];
foreach ($requested as $uid) {
if (in_array($uid, $arrived, true)) {
continue;
}

$dropped[] = new DroppedSource(
$kind,
$uid,
in_array($uid, $existing, true)
? DroppedSourceReason::DEACTIVATED
: DroppedSourceReason::GONE,
);
}

return $dropped;
}

/**
* @param list<Skill>|list<PromptSnippet> $records
*
* @return list<int>
*/
private function uidsOf(array $records): array
{
$uids = [];
foreach ($records as $record) {
$uid = $record->getUid();
if ($uid !== null) {
$uids[] = $uid;
}
}

return $uids;
}
}
5 changes: 5 additions & 0 deletions Classes/Service/Agent/Timeline/RunTimelineFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@
'contentRedacted',
'approved',
'decidedBy',
// The forced sources a run asked for and did not get (ADR-179). On the
// list because the absence is invisible everywhere else: a source that
// never arrived leaves no mark in the transcript, which is exactly why
// the operator cannot see it today.
'droppedSources',
];

public function __construct(
Expand Down
16 changes: 16 additions & 0 deletions Classes/Service/Tool/RunAugmentation.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

use Netresearch\NrLlm\Domain\Model\PromptSnippet;
use Netresearch\NrLlm\Domain\Model\Skill;
use Netresearch\NrLlm\Domain\ValueObject\DroppedSource;
use Netresearch\NrLlm\Domain\ValueObject\InjectedContext;

/**
Expand All @@ -33,11 +34,26 @@
/**
* @param list<Skill> $forcedSkills
* @param list<PromptSnippet> $forcedSnippets
* @param list<DroppedSource> $droppedSources sources asked for that did not arrive
*/
public function __construct(
public array $forcedSkills = [],
public array $forcedSnippets = [],
public bool $dryRun = false,
/**
* Forced sources this run asked for and did not get (ADR-179).
*
* Empty is the normal case and means "nothing was dropped", never
* "not checked": a caller that builds the augmentation by hand has
* nothing to compare against, and a run whose sources all resolved
* is indistinguishable from it — deliberately, because both are the
* same statement about what reached the model.
*
* Appended last with a default, so every existing caller keeps
* working. The class is on the frozen surface, so the growth is
* announced rather than silent.
*/
public array $droppedSources = [],
) {}

/**
Expand Down
24 changes: 24 additions & 0 deletions Classes/Service/Tool/RunTrace.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Netresearch\NrLlm\Domain\Model\CompletionResponse;
use Netresearch\NrLlm\Domain\ValueObject\ChatMessage;
use Netresearch\NrLlm\Domain\ValueObject\ContextBudgetBreakdown;
use Netresearch\NrLlm\Domain\ValueObject\DroppedSource;
use Netresearch\NrLlm\Domain\ValueObject\RunStep;
use Netresearch\NrLlm\Domain\ValueObject\ToolArtifact;
use Netresearch\NrLlm\Domain\ValueObject\ToolCall;
Expand Down Expand Up @@ -108,6 +109,29 @@ public function recordRequest(int $round, array $messagesSent, array $toolSpecs)
* the question this answers, and a run only reaches the interesting answer
* after the boring ones.
*/
/**
* Record the forced sources this run asked for and did not get (ADR-179).
*
* Called once, before the first round, and only with a non-empty list: a
* run whose sources all resolved records nothing, so the step's presence
* is the signal and an empty step would be noise on every other run.
*
* @param list<DroppedSource> $dropped
*/
public function recordDroppedSources(array $dropped): void
{
if ($dropped === []) {
return;
}

$this->add(new RunStep(
kind: RunStep::KIND_DROPPED,
round: 0,
durationMs: 0.0,
droppedSources: $dropped,
));
}

public function recordContextBudget(int $round, ContextBudgetBreakdown $breakdown): void
{
$this->add(new RunStep(
Expand Down
13 changes: 13 additions & 0 deletions Classes/Service/Tool/ToolLoopService.php
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,19 @@ public function runLoop(
if ($skipAssembly) {
$dryRun = false;
} else {
// Before anything is assembled: what the run asked for and did not
// get (ADR-179). Recorded on the resolve side rather than inferred
// from the assembled messages, because a source that never arrived
// leaves no trace in them — that absence is exactly what an
// operator cannot see today.
//
// Not on the resume branch above: ADR-166 and ADR-175 keep a
// deactivated source resolving there on purpose, so nothing is
// dropped and a step would imply otherwise.
if ($augmentation instanceof RunAugmentation) {
$runTrace?->recordDroppedSources($augmentation->droppedSources);
}

[$messages, $dryRun] = $this->assemble($messages, $configuration, $options, $augmentation);
}

Expand Down
Loading
Loading