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
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<description lang="nl"><![CDATA[Hermiq brengt autonome AI-agents naar Nextcloud. Definieer een agent, geef deze tools, en laat hem volgens een schema draaien: dagelijkse rapportages, inbox-triage, monitoring, ochtendbriefings. Alles binnen je eigen Nextcloud, afgeleverd in Nextcloud Talk. Agents, hun geheugen en hun zelflerende skills worden opgeslagen als OpenRegister-objecten, zodat elke run beheerd, controleerbaar en multi-tenant is.

**Vereist:** [OpenRegister](https://apps.nextcloud.com/apps/openregister) 0.2.17-unstable.14 of hoger (installeer/upgrade via de [Nextcloud App Store](https://apps.nextcloud.com/apps/openregister)). Hermiq's agent-engine (chat, geplande runs, flow-getriggerde agent-acties) roept OpenRegister's `ToolRegistryFacade` aan en luistert naar diens `AgentRunRequestedEvent`, die beide pas vanaf die OpenRegister-versie bestaan; de sectie "Agent-credentials" in Instellingen en de persoonlijke/organisatie-credentialresolutie hebben daarnaast OpenRegister's organisatiebrede credential broker nodig, die sinds diezelfde versie wordt geleverd.]]></description>
<version>0.2.17-unstable.20261103120000</version>
<version>0.2.17-unstable.20261103233000</version>
<licence>EUPL-1.2</licence>
<author mail="info@conduction.nl" homepage="https://www.conduction.nl/">Conduction</author>
<namespace>Hermiq</namespace>
Expand Down
2 changes: 2 additions & 0 deletions l10n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -1113,6 +1113,8 @@ OC.L10N.register(
"The agent this budget guards": "The agent this budget guards",
"The agent to answer in that conversation": "The agent to answer in that conversation",
"The agent was saved, but its tool grants were not updated. Only the agent's owner may change them.": "The agent was saved, but its tool grants were not updated. Only the agent's owner may change them.",
"Could not read the agent's current tool grants, so nothing was saved. Try again.": "Could not read the agent's current tool grants, so nothing was saved. Try again.",
"The tool grants of this agent were changed elsewhere after you opened this form, so nothing was saved. Close the form and open it again to see the current grants.": "The tool grants of this agent were changed elsewhere after you opened this form, so nothing was saved. Close the form and open it again to see the current grants.",
"The answer is parsed, so a later node can read one field with {field} — not just the whole reply.": "The answer is parsed, so a later node can read one field with {field} — not just the whole reply.",
"The authoritative agent inventory and create-time quota reject live in OpenRegister.": "The authoritative agent inventory and create-time quota reject live in OpenRegister.",
"The bot is not enabled in this conversation, so nothing will answer here.": "The bot is not enabled in this conversation, so nothing will answer here.",
Expand Down
2 changes: 2 additions & 0 deletions l10n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1124,6 +1124,8 @@
"The agent this budget guards": "The agent this budget guards",
"The agent to answer in that conversation": "The agent to answer in that conversation",
"The agent was saved, but its tool grants were not updated. Only the agent's owner may change them.": "The agent was saved, but its tool grants were not updated. Only the agent's owner may change them.",
"Could not read the agent's current tool grants, so nothing was saved. Try again.": "Could not read the agent's current tool grants, so nothing was saved. Try again.",
"The tool grants of this agent were changed elsewhere after you opened this form, so nothing was saved. Close the form and open it again to see the current grants.": "The tool grants of this agent were changed elsewhere after you opened this form, so nothing was saved. Close the form and open it again to see the current grants.",
"The answer is parsed, so a later node can read one field with {field} — not just the whole reply.": "The answer is parsed, so a later node can read one field with {field} — not just the whole reply.",
"The authoritative agent inventory and create-time quota reject live in OpenRegister.": "The authoritative agent inventory and create-time quota reject live in OpenRegister.",
"The bot is not enabled in this conversation, so nothing will answer here.": "The bot is not enabled in this conversation, so nothing will answer here.",
Expand Down
2 changes: 2 additions & 0 deletions l10n/nl.js
Original file line number Diff line number Diff line change
Expand Up @@ -1222,6 +1222,8 @@ OC.L10N.register(
"The agent this budget guards": "De agent die dit budget bewaakt",
"The agent to answer in that conversation": "De agent die in dat gesprek antwoordt",
"The agent was saved, but its tool grants were not updated. Only the agent's owner may change them.": "De agent is opgeslagen, maar de toolrechten zijn niet bijgewerkt. Alleen de eigenaar van de agent mag deze wijzigen.",
"Could not read the agent's current tool grants, so nothing was saved. Try again.": "De huidige toolrechten van de agent konden niet worden gelezen, dus er is niets opgeslagen. Probeer het opnieuw.",
"The tool grants of this agent were changed elsewhere after you opened this form, so nothing was saved. Close the form and open it again to see the current grants.": "De toolrechten van deze agent zijn elders gewijzigd nadat je dit formulier opende, dus er is niets opgeslagen. Sluit het formulier en open het opnieuw om de huidige rechten te zien.",
"The answer is parsed, so a later node can read one field with {field} — not just the whole reply.": "Het antwoord wordt geparsed, zodat een latere node één veld kan lezen met {field} — niet alleen het hele antwoord.",
"The authoritative agent inventory and create-time quota reject live in OpenRegister.": "De gezaghebbende agent-inventaris en de weigering bij het bereiken van het quotum bij aanmaak leven in OpenRegister.",
"The bot is not enabled in this conversation, so nothing will answer here.": "De bot is niet ingeschakeld in dit gesprek, dus hier antwoordt niets.",
Expand Down
2 changes: 2 additions & 0 deletions l10n/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -1239,6 +1239,8 @@
"The agent this budget guards": "De agent die dit budget bewaakt",
"The agent to answer in that conversation": "De agent die in dat gesprek antwoordt",
"The agent was saved, but its tool grants were not updated. Only the agent's owner may change them.": "De agent is opgeslagen, maar de toolrechten zijn niet bijgewerkt. Alleen de eigenaar van de agent mag deze wijzigen.",
"Could not read the agent's current tool grants, so nothing was saved. Try again.": "De huidige toolrechten van de agent konden niet worden gelezen, dus er is niets opgeslagen. Probeer het opnieuw.",
"The tool grants of this agent were changed elsewhere after you opened this form, so nothing was saved. Close the form and open it again to see the current grants.": "De toolrechten van deze agent zijn elders gewijzigd nadat je dit formulier opende, dus er is niets opgeslagen. Sluit het formulier en open het opnieuw om de huidige rechten te zien.",
"The answer is parsed, so a later node can read one field with {field} — not just the whole reply.": "Het antwoord wordt geparsed, zodat een latere node één veld kan lezen met {field} — niet alleen het hele antwoord.",
"The authoritative agent inventory and create-time quota reject live in OpenRegister.": "De gezaghebbende agent-inventaris en de weigering bij het bereiken van het quotum bij aanmaak leven in OpenRegister.",
"The bot is not enabled in this conversation, so nothing will answer here.": "De bot is niet ingeschakeld in dit gesprek, dus hier antwoordt niets.",
Expand Down
7 changes: 6 additions & 1 deletion lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@
use OCA\Hermiq\Tenant\OpenRegisterManageableOrganisations;
use OCA\OpenRegister\Contract\RegisterSlugResolverInterface;
use OCA\OpenRegister\Event\AgentRunRequestedEvent;
use OCA\OpenRegister\Event\DraftCreatedEvent;
use OCA\OpenRegister\Event\DraftUpdatedEvent;
use OCA\OpenRegister\Event\ObjectCreatedEvent;
use OCA\OpenRegister\Event\ObjectDeletedEvent;
use OCA\OpenRegister\Event\ObjectUpdatedEvent;
Expand Down Expand Up @@ -452,7 +454,8 @@ protected function addInitScript(string $suffix): void {
*
* - Each Talk-enabled agent carries its own Talk bot, because the bot record
* is the ONLY carrier of the name Talk displays (talk-agent-sessions).
* - Adding or removing an approval waiver is audited on every write.
* - Adding or removing an approval waiver is audited on every write, drafts
* included: OpenRegister raises only the draft events for a draft.
*
* @param IRegistrationContext $context The registration context.
*
Expand All @@ -467,6 +470,8 @@ private function registerAgentObjectListeners(IRegistrationContext $context): vo

$context->registerEventListener(event: ObjectCreatedEvent::class, listener: AgentWaiverChangeListener::class);
$context->registerEventListener(event: ObjectUpdatedEvent::class, listener: AgentWaiverChangeListener::class);
$context->registerEventListener(event: DraftCreatedEvent::class, listener: AgentWaiverChangeListener::class);
$context->registerEventListener(event: DraftUpdatedEvent::class, listener: AgentWaiverChangeListener::class);
}//end registerAgentObjectListeners()

/**
Expand Down
52 changes: 47 additions & 5 deletions lib/Listener/AgentWaiverChangeListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
* Records an approval waiver added or removed by any write to an agent: through
* Hermiq's tool-grants endpoint, and through OpenRegister's generic objects API
* (ADR-022: agents are OpenRegister objects, so the object events are the only
* hook that sees every write).
* hook that sees every write). A draft agent raises the draft events instead,
* and its owner can already run it, so those are audited too.
*
* Never throws: an agent write must not fail because its audit could not be written.
*
Expand All @@ -32,6 +33,8 @@
use OCA\Hermiq\Service\Agent\AgentObjectMatcher;
use OCA\Hermiq\Service\Agent\GrantWaiverAudit;
use OCA\OpenRegister\Db\ObjectEntity;
use OCA\OpenRegister\Event\DraftCreatedEvent;
use OCA\OpenRegister\Event\DraftUpdatedEvent;
use OCA\OpenRegister\Event\ObjectCreatedEvent;
use OCA\OpenRegister\Event\ObjectUpdatedEvent;
use OCP\EventDispatcher\Event;
Expand All @@ -50,6 +53,18 @@
*/
class AgentWaiverChangeListener implements IEventListener {

/**
* Uuids of agents whose save just left draft.
*
* Leaving draft raises DraftUpdatedEvent for the save and then
* ObjectCreatedEvent for the same object. The waivers were recorded on the
* draft path, so that created event is not recorded again. One listener
* instance serves every registration in a request.
*
* @var array<string, true>
*/
private array $leftDraft = [];

/**
* Constructor.
*
Expand All @@ -65,7 +80,7 @@ public function __construct(
}//end __construct()

/**
* Record the waivers a created or updated agent gained or lost; other events are ignored.
* Record the waivers a created or updated agent, draft or not, gained or lost; other events are ignored.
*
* @param Event $event The dispatched OpenRegister object event.
*
Expand All @@ -74,19 +89,46 @@ public function __construct(
* @spec openspec/specs/agent-capability-reach/spec.md#requirement-waiving-approval-is-recorded-as-a-distinct-audited-event
*/
public function handle(Event $event): void {
[$agent, $before] = $this->written(event: $event);
try {
if ($agent !== null && $this->agents->isAgent(object: $agent) === true) {
$this->waiverAudit->record(agent: $agent, before: $this->waiverAudit->waivers(tools: $before));
[$agent, $before] = $this->written(event: $event);
if ($agent === null || $this->agents->isAgent(object: $agent) === false || $this->announcesLeftDraft(event: $event, agent: $agent) === true) {
return;
}

$this->waiverAudit->record(agent: $agent, before: $this->waiverAudit->waivers(tools: $before));
if ($event instanceof DraftUpdatedEvent && $agent->isDraft() === false) {
$this->leftDraft[(string)$agent->getUuid()] = true;
}
} catch (Throwable $e) {
$this->logger->warning('[AgentWaiverChangeListener] agent write not audited: ' . $e->getMessage());
}
}//end handle()

/**
* Whether this is the created event that follows an agent's save out of draft, already recorded.
*
* @param Event $event The dispatched event.
* @param ObjectEntity $agent The agent it carries.
*
* @return bool True for that created event; it is forgotten once seen.
*
* @spec openspec/specs/agent-capability-reach/spec.md#requirement-waiving-approval-is-recorded-as-a-distinct-audited-event
*/
private function announcesLeftDraft(Event $event, ObjectEntity $agent): bool {
$uuid = (string)$agent->getUuid();
if ($event instanceof DraftCreatedEvent || isset($this->leftDraft[$uuid]) === false || ($event instanceof ObjectCreatedEvent) === false) {
return false;
}

unset($this->leftDraft[$uuid]);
return true;
}//end announcesLeftDraft()

/**
* The object a create or update stored, and its `tools` before the write.
*
* The draft events extend these two, so they are read the same way.
*
* @param Event $event The dispatched event.
*
* @return array{0: ObjectEntity|null, 1: mixed} The stored object (null for other events) and the previous tools.
Expand Down
12 changes: 8 additions & 4 deletions lib/Repair/CheckOpenRegisterCompatibility.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
use OCA\OpenRegister\Event\AgentRunRequestedEvent;
use OCA\OpenRegister\Service\Capability\ToolGrantResolver;
use OCA\OpenRegister\Service\Mcp\ToolRegistryFacade;
use OCA\OpenRegister\Service\Object\BulkWriteEvents;
use OCP\Migration\IOutput;
use OCP\Migration\IRepairStep;
use Psr\Log\LoggerInterface;
Expand All @@ -65,11 +66,13 @@ class CheckOpenRegisterCompatibility implements IRepairStep {
* OpenRegister's organisation-scope credential broker
* (`credential-broker-organisation-scope`), which the "Agent credentials"
* Settings section and `CredentialScopeResolver`'s organisation branch
* depend on.
* depend on. Bumped for the approval-waiver audit: the first tag holding
* OpenRegister 959be02f8, which reads `fireEventsOnBulkWrites`. An older
* version drops that setting, so a bulk save of agents leaves no waiver record.
*
* @var string
*/
public const MIN_OPENREGISTER_VERSION = '0.2.17-unstable.14';
public const MIN_OPENREGISTER_VERSION = '2.1.43-unstable.20261102171844';

/**
* FQCN => human-readable label (with the OpenRegister PR that introduced it),
Expand All @@ -91,6 +94,7 @@ class CheckOpenRegisterCompatibility implements IRepairStep {
// class arrived in the same commit, and listing five would suggest they
// can be present independently.
ToolGrantResolver::class => 'OCA\OpenRegister\Service\Capability\ToolGrantResolver (ADR-099 §5 relocation)',
BulkWriteEvents::class => 'OCA\OpenRegister\Service\Object\BulkWriteEvents (without it a bulk save of agents leaves no waiver record)',
];

/**
Expand Down Expand Up @@ -155,8 +159,8 @@ public function run(IOutput $output): void {

$message = sprintf(
'Hermiq requires OpenRegister >= %s but the following required classes are missing: %s. '
. 'The agent engine (chat, scheduled runs, flow-triggered agent actions) will fail to '
. 'construct until OpenRegister is upgraded on this instance.',
. 'The agent engine (chat, scheduled runs, flow-triggered agent actions) can fail, '
. 'and bulk saves can go unaudited, until OpenRegister is upgraded on this instance.',
self::MIN_OPENREGISTER_VERSION,
implode(', ', $missing)
);
Expand Down
37 changes: 12 additions & 25 deletions lib/Service/ApprovalPreviewBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

namespace OCA\Hermiq\Service;

use OCA\Hermiq\Service\Talk\TalkSafeText;
use OCA\OpenRegister\Service\Capability\ToolGrantResolver;
use OCA\OpenRegister\Service\Capability\ToolReachResolver;
use OCA\OpenRegister\Service\Mcp\ToolRegistryFacade;
Expand All @@ -51,15 +52,6 @@ class ApprovalPreviewBuilder {
*/
private const TOOL_SOURCES = ['tool', 'toolcall', 'workspace-run'];

/**
* A tool label Talk shows as plain text: letters, digits, spaces and
* `, ' ( ) -`, at most 80 characters. No `@`, no markdown, no dots, so no
* mention, link, autolinked domain or formatting can form.
*
* @var string
*/
private const TALK_SAFE_LABEL = '/^[\\p{L}\\p{M}\\p{N} ,\'’()\\-]{1,80}$/u';

/**
* English reach labels for text that leaves the UI (the Talk line).
*
Expand Down Expand Up @@ -141,13 +133,13 @@ public function build(array $approval): array {
*/
public function toolLine(string $toolId): string {
$tool = $this->describeTool(id: $toolId, arguments: null);
$reach = (self::REACH_TEXT[$tool['reach']] ?? 'an unknown reach');
$label = $this->talkLabel(tool: $tool);
if ($label === null) {
return sprintf('A tool %s, and reaches %s.', $tool['effect'], $reach);
}

return sprintf(
'The tool %s %s, and reaches %s.',
$this->talkLabel(tool: $tool),
$tool['effect'],
(self::REACH_TEXT[$tool['reach']] ?? 'an unknown reach')
);
return sprintf('The tool %s %s, and reaches %s.', $label, $tool['effect'], $reach);
}//end toolLine()

/**
Expand All @@ -156,23 +148,18 @@ public function toolLine(string $toolId): string {
* Talk reads mentions, links and markdown in message text, and a tool's title
* and name are free text from whoever registered the tool. A label is used only
* when it holds letters, digits, spaces and plain punctuation; otherwise the
* message names the tool by its id.
* message names the tool by its id, when that is plain too.
*
* @param array{id: string, name: string, title?: string} $tool The described tool.
*
* @return string The title, the name, or the id.
* @return string|null The title, the name or the id; null when none is plain.
*
* @spec openspec/specs/human-approval-gate/spec.md#requirement-the-talk-request-names-the-tool-and-its-reach-req-apprev-002
*/
private function talkLabel(array $tool): string {
foreach ([($tool['title'] ?? ''), $tool['name']] as $label) {
$label = trim((string)preg_replace('/[\s\p{C}]+/u', ' ', $label));
if ($label !== '' && preg_match(self::TALK_SAFE_LABEL, $label) === 1) {
return $label;
}
}
private function talkLabel(array $tool): ?string {
$safe = new TalkSafeText();

return $tool['id'];
return ($safe->label(text: (string)($tool['title'] ?? '')) ?? $safe->label(text: $tool['name']) ?? $safe->identifier(identifier: $tool['id']));
}//end talkLabel()

/**
Expand Down
13 changes: 10 additions & 3 deletions lib/Service/DeliveryService.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
use OCA\Hermiq\Service\Connection\ConnectionReporter;
use OCA\Hermiq\Service\Talk\TalkApprovalNotifier;
use OCA\Hermiq\Service\Talk\TalkRoomBinding;
use OCA\Hermiq\Service\Talk\TalkSafeText;
use OCA\OpenRegister\Db\ObjectEntity;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
Expand Down Expand Up @@ -591,7 +592,7 @@ private function notifyApprovalReviewers(
if ($talkOk === true) {
$this->tryPostToNoteToSelf(
owner: $uid,
output: sprintf('Approval needed for “%s”. Review it in Hermiq.', $displayName)
output: sprintf('Approval needed for %s. Review it in Hermiq.', (new TalkSafeText())->quoted(name: $displayName, fallback: 'an action'))
);
}
}//end foreach
Expand Down Expand Up @@ -960,7 +961,8 @@ private function tryPostToTargetRoom(string $token, string $owner, string $outpu
* The Talk text for a held tool call: what needs approval, and one line
* naming the tool and how far it reaches, so the reviewer knows before
* opening Hermiq. Falls back to the text without that line when the preview
* builder cannot be had; the approval itself is never affected.
* builder cannot be had; the approval itself is never affected. A tool id
* Talk could read as a mention, link or markdown is not put in the text.
*
* @param string $toolId The held tool id.
*
Expand All @@ -979,7 +981,12 @@ private function toolApprovalTalkText(string $toolId): string {
$this->logger->debug('[DeliveryService] no tool line for the approval message: ' . $e->getMessage());
}

return sprintf('Approval needed to run “%s”.%s Review it in Hermiq.', $toolId, $line);
$name = (new TalkSafeText())->identifier(identifier: $toolId);
if ($name === null) {
return sprintf('Approval needed to run a tool.%s Review it in Hermiq.', $line);
}

return sprintf('Approval needed to run “%s”.%s Review it in Hermiq.', $name, $line);
}//end toolApprovalTalkText()

/**
Expand Down
Loading
Loading