Skip to content

Commit fa262e5

Browse files
feat(directory): add Jidoka agent discovery projection (#92)
1 parent c659411 commit fa262e5

17 files changed

Lines changed: 1877 additions & 17 deletions

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,9 @@ defmodule MyApp.Messaging do
157157
end
158158
```
159159

160+
The SQLite adapter stores canonical rooms, participants, messages, threads,
161+
bridge bindings, routing policies, bridge configs, ingress subscriptions, and
162+
safe Jidoka agent directory projections.
160163
The SQLite adapter stores canonical rooms, participants, principals, scoped
161164
identity bindings, messages, threads, bridge bindings, Jidoka messaging
162165
endpoints, endpoint room memberships, thread routes, principal memberships,
@@ -239,6 +242,46 @@ helpers enforce the same instance and room scope before they invoke the
239242
projection. This keeps a failed optional index from changing canonical message
240243
commit behavior. Small deployments can omit a projection.
241244

245+
### Scoped Jidoka Agent Discovery
246+
247+
A Jidoka-owned adapter can publish a strict display projection without making
248+
Jido Messaging an agent authoring or execution surface:
249+
250+
```elixir
251+
{:ok, projection} =
252+
MyApp.Messaging.project_jidoka_agent(%{
253+
jidoka_agent_ref: %{system: :jidoka, id: "support-guide"},
254+
principal_id: "principal:support-guide",
255+
endpoint_ref: %{system: :jido_messaging, id: "endpoint:support-guide"},
256+
name: "Support Guide",
257+
description: "Answers product support questions.",
258+
capabilities: ["support", "text"],
259+
availability: :available,
260+
version: "1.0.0",
261+
invocation_summary: %{mode: :thread, approval: :may_require},
262+
verification_state: :verified,
263+
listing_state: :listed,
264+
source_revision: 1,
265+
source_updated_at: DateTime.utc_now(),
266+
fresh_for_seconds: 300
267+
})
268+
269+
{:ok, scope} =
270+
MyApp.Messaging.agent_directory_scope(%{
271+
"endpoint:support-guide" => "principal:support-guide"
272+
})
273+
274+
{:ok, agents} =
275+
MyApp.Messaging.search_jidoka_agents(%{capability: "support"}, scope)
276+
```
277+
278+
The application must build the scope from current messaging membership and
279+
authorization results. An unbound or out-of-scope endpoint does not appear.
280+
Freshness, availability, verification, capability, and `invokable` values are
281+
display data. They do not grant invocation. See
282+
[Jidoka Agent Discovery Projection](docs/jidoka-agent-discovery.md) for the
283+
Jidoka ownership boundary, adapter callback, safe field set, and revision
284+
rules. The core package does not depend on Jidoka or `jido_harness`.
242285
### Jidoka-Linked Messaging Activity
243286

244287
A trusted Jidoka-owned adapter can store a small messaging activity projection

docs/jidoka-agent-discovery.md

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
# Jidoka Agent Discovery Projection
2+
3+
Jido Messaging provides a small, safe directory projection for Jidoka agents.
4+
Jidoka stays the source of agent definitions and capability data. Jido
5+
Messaging stores only the data that a messaging client needs for discovery.
6+
7+
## Ownership boundary
8+
9+
Jidoka owns:
10+
11+
- `Agent.Spec` and agent versions;
12+
- instructions, models, tools, controls, and credentials;
13+
- process hosting, availability inputs, sessions, memory, and execution;
14+
- the decision about which source fields are safe to publish.
15+
16+
Jido Messaging owns:
17+
18+
- the canonical messaging principal reference;
19+
- the canonical messaging endpoint reference;
20+
- the scoped directory query contract;
21+
- the safe display projection and its source revision;
22+
- persistent ETS and SQLite projection records.
23+
24+
Jido Messaging does not depend on Jidoka or `jido_harness`. A Jidoka-owned
25+
integration package depends on Jido Messaging and implements
26+
`Jido.Messaging.AgentDirectoryProjector`.
27+
28+
## Projection contract
29+
30+
`Jido.Messaging.AgentDirectoryProjection` accepts only these source fields:
31+
32+
- an opaque Jidoka agent reference with `system` and `id`;
33+
- a canonical messaging principal ID;
34+
- an optional canonical messaging endpoint reference;
35+
- a safe name and description;
36+
- at most 32 safe capability codes;
37+
- an availability summary and agent version;
38+
- a small invocation mode and approval summary;
39+
- an integration-reported verification state;
40+
- a listing state, source revision, source time, and bounded freshness period.
41+
42+
The Jidoka reference has this shape:
43+
44+
```elixir
45+
%{"system" => "jidoka", "id" => "support-guide"}
46+
```
47+
48+
The endpoint reference has this shape:
49+
50+
```elixir
51+
%Jido.Messaging.AgentDirectoryEndpointRef{
52+
system: "jido_messaging",
53+
id: "endpoint:support-guide"
54+
}
55+
```
56+
57+
The projection does not accept an agent specification, instructions, prompts,
58+
models, tools, functions, controls, credentials, tokens, private keys,
59+
environment data, sessions, memory, or runtime records. The name, description,
60+
and capability codes are still an integration trust boundary. The Jidoka
61+
adapter must create redacted display text. It must not copy instruction text
62+
or tool configuration into these fields.
63+
64+
## Publishing from Jidoka
65+
66+
A Jidoka-owned adapter can map its source data to a projection:
67+
68+
```elixir
69+
defmodule MyApp.JidokaDirectoryProjector do
70+
@behaviour Jido.Messaging.AgentDirectoryProjector
71+
72+
@impl true
73+
def to_directory_projection(agent_spec, context, _opts) do
74+
{:ok,
75+
%{
76+
jidoka_agent_ref: %{system: :jidoka, id: context.agent_id},
77+
principal_id: context.principal_id,
78+
endpoint_ref: %{system: :jido_messaging, id: context.endpoint_id},
79+
name: context.safe_name,
80+
description: context.safe_description,
81+
capabilities: context.safe_capability_codes,
82+
availability: context.availability,
83+
version: context.version,
84+
invocation_summary: %{mode: :thread, approval: :may_require},
85+
verification_state: :verified,
86+
listing_state: :listed,
87+
source_revision: context.revision,
88+
source_updated_at: context.source_updated_at,
89+
fresh_for_seconds: 300
90+
}}
91+
end
92+
end
93+
94+
{:ok, projection} =
95+
MyApp.Messaging.project_jidoka_agent_from(
96+
MyApp.JidokaDirectoryProjector,
97+
jidoka_agent_spec,
98+
safe_projection_context
99+
)
100+
```
101+
102+
The adapter source and context are transient. The strict projection
103+
constructor validates the returned map before persistence. An integration can
104+
also call `project_jidoka_agent/1` with a map that it has already redacted.
105+
106+
The first source revision is `1`. Later revisions must be sequential. Equal
107+
content at the same revision is idempotent. Conflicting equal revisions, old
108+
revisions, and revision gaps fail. This prevents old availability or endpoint
109+
data from replacing a newer projection.
110+
111+
Use `listing_state: :withdrawn` at a new revision to remove an agent from
112+
search without loss of the revision marker.
113+
114+
## Scoped discovery
115+
116+
Agent search always needs an `AgentDirectoryScope`. The scope maps endpoint
117+
IDs to principal IDs:
118+
119+
```elixir
120+
{:ok, scope} =
121+
MyApp.Messaging.agent_directory_scope(%{
122+
"endpoint:support-guide" => "principal:support-guide"
123+
})
124+
125+
{:ok, entries} =
126+
MyApp.Messaging.search_jidoka_agents(
127+
%{capability: "support", invokable: true},
128+
scope,
129+
limit: 20
130+
)
131+
```
132+
133+
An application must build the map from current active room memberships and a
134+
current authorization result. It must not build it from user input. After the
135+
principal grant and durable endpoint work is integrated, the Jidoka adapter
136+
must use those contracts to construct the scope. The directory does not create
137+
membership or grant access.
138+
139+
Search returns a projection only when its endpoint and principal pair is in
140+
the supplied scope. A projection without an endpoint is not returned. This
141+
lets an integration store a safe Jidoka projection before it has a usable
142+
messaging binding without presentation of the agent as invokable.
143+
144+
`directory_search(:agent, query, scope: scope)` and
145+
`directory_lookup(:agent, query, scope: scope)` use the same rules. Existing
146+
participant and room directory queries do not change.
147+
148+
## Freshness and invocation
149+
150+
Each result reports `freshness` as `:fresh` or `:stale`. `invokable` is true
151+
only when all of these conditions are true:
152+
153+
- the projection is listed;
154+
- the scoped endpoint exists in the result;
155+
- projected availability is `:available`;
156+
- verification is not rejected;
157+
- the freshness period has not ended.
158+
159+
`invokable` is a user interface hint. It is not an authorization decision.
160+
The caller must get a current invocation decision before it sends a request.
161+
The verification state is also an integration-reported display value. It does
162+
not prove controller identity, grant access, or establish reputation.
163+
164+
Availability can differ from the current Jidoka runtime. A short freshness
165+
period and frequent revision updates reduce this risk. Freshness is limited to
166+
24 hours. Source times more than five minutes in the future fail validation.
167+
168+
## Persistence and compatibility
169+
170+
ETS and SQLite store the same safe projection. SQLite keeps projections across
171+
a messaging restart. Deletion of a canonical participant also deletes its
172+
directory projection.
173+
174+
The new persistence callbacks are optional. A custom persistence adapter that
175+
does not implement them returns `{:error, :unsupported}` from the projection
176+
API. The PostgreSQL adapter must add these callbacks when the persistence work
177+
is combined.
178+
179+
The current additive implementation validates the principal through the agent
180+
participant record. After canonical principals and durable Jidoka endpoints
181+
are merged, the integration must align this validation with those records.
182+
When the endpoint callbacks are present, publication also checks that the
183+
endpoint is active and belongs to the projected principal.
184+
185+
## Security and privacy rules
186+
187+
- Do not put `Agent.Spec`, prompts, tools, controls, or private runtime data in
188+
the projection.
189+
- Do not show an endpoint that is not in the current caller scope.
190+
- Do not treat projected capability, verification, availability, or
191+
`invokable` as authorization.
192+
- Do not expose room membership, controller identity, or grant details in a
193+
directory result.
194+
- Publish a withdrawal revision when an agent must no longer be discoverable.
195+
- Keep the freshness period short enough for the Jidoka availability source.
196+
- Keep source revision state durable so that old events cannot publish stale
197+
data again.

lib/jido_messaging.ex

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -857,15 +857,99 @@ defmodule Jido.Messaging do
857857
# Directory functions
858858

859859
@doc "Lookup a single directory entry."
860-
def directory_lookup(target, query, opts \\ []) do
860+
def directory_lookup(target, query, opts \\ [])
861+
862+
def directory_lookup(:agent, query, opts) do
863+
case Keyword.fetch(opts, :scope) do
864+
{:ok, scope} ->
865+
Jido.Messaging.AgentDirectory.lookup(
866+
__MODULE__,
867+
__jido_messaging__(:runtime),
868+
query,
869+
scope,
870+
Keyword.delete(opts, :scope)
871+
)
872+
873+
:error ->
874+
{:error, :agent_directory_scope_required}
875+
end
876+
end
877+
878+
def directory_lookup(target, query, opts) do
861879
Jido.Messaging.directory_lookup(__jido_messaging__(:runtime), target, query, opts)
862880
end
863881

864882
@doc "Search directory entries."
865-
def directory_search(target, query, opts \\ []) do
883+
def directory_search(target, query, opts \\ [])
884+
885+
def directory_search(:agent, query, opts) do
886+
case Keyword.fetch(opts, :scope) do
887+
{:ok, scope} ->
888+
Jido.Messaging.AgentDirectory.search(
889+
__MODULE__,
890+
__jido_messaging__(:runtime),
891+
query,
892+
scope,
893+
Keyword.delete(opts, :scope)
894+
)
895+
896+
:error ->
897+
{:error, :agent_directory_scope_required}
898+
end
899+
end
900+
901+
def directory_search(target, query, opts) do
866902
Jido.Messaging.directory_search(__jido_messaging__(:runtime), target, query, opts)
867903
end
868904

905+
@doc "Publish a safe Jidoka agent projection into the messaging directory."
906+
def project_jidoka_agent(attrs) do
907+
Jido.Messaging.AgentDirectory.project(__jido_messaging__(:runtime), attrs)
908+
end
909+
910+
@doc "Publish a safe Jidoka agent projection through a Jidoka-owned adapter."
911+
def project_jidoka_agent_from(projector, source, context \\ %{}, opts \\ []) do
912+
Jido.Messaging.AgentDirectory.project_from(
913+
__jido_messaging__(:runtime),
914+
projector,
915+
source,
916+
context,
917+
opts
918+
)
919+
end
920+
921+
@doc "Get a stored Jidoka agent directory projection by ID."
922+
def get_jidoka_agent_projection(projection_id) do
923+
Jido.Messaging.AgentDirectory.get(__jido_messaging__(:runtime), projection_id)
924+
end
925+
926+
@doc "Build an explicit agent directory scope for this messaging instance."
927+
def agent_directory_scope(endpoint_principals, metadata \\ %{}) do
928+
Jido.Messaging.AgentDirectoryScope.new(__MODULE__, endpoint_principals, metadata)
929+
end
930+
931+
@doc "Search safe Jidoka agent projections within an explicit scope."
932+
def search_jidoka_agents(query, scope, opts \\ []) do
933+
Jido.Messaging.AgentDirectory.search(
934+
__MODULE__,
935+
__jido_messaging__(:runtime),
936+
query,
937+
scope,
938+
opts
939+
)
940+
end
941+
942+
@doc "Lookup one safe Jidoka agent projection within an explicit scope."
943+
def lookup_jidoka_agent(query, scope, opts \\ []) do
944+
Jido.Messaging.AgentDirectory.lookup(
945+
__MODULE__,
946+
__jido_messaging__(:runtime),
947+
query,
948+
scope,
949+
opts
950+
)
951+
end
952+
869953
# Onboarding functions
870954

871955
@doc "Start (or resume) an onboarding flow."
@@ -2019,15 +2103,23 @@ defmodule Jido.Messaging do
20192103
@doc "Lookup a single directory entry."
20202104
def directory_lookup(runtime, target, query, opts \\ [])
20212105
when is_atom(target) and is_map(query) and is_list(opts) do
2022-
{persistence, persistence_state} = Runtime.get_persistence(runtime)
2023-
persistence.directory_lookup(persistence_state, target, query, opts)
2106+
if target == :agent do
2107+
Jido.Messaging.AgentDirectory.lookup_from_directory(runtime, query, opts)
2108+
else
2109+
{persistence, persistence_state} = Runtime.get_persistence(runtime)
2110+
persistence.directory_lookup(persistence_state, target, query, opts)
2111+
end
20242112
end
20252113

20262114
@doc "Search directory entries."
20272115
def directory_search(runtime, target, query, opts \\ [])
20282116
when is_atom(target) and is_map(query) and is_list(opts) do
2029-
{persistence, persistence_state} = Runtime.get_persistence(runtime)
2030-
persistence.directory_search(persistence_state, target, query, opts)
2117+
if target == :agent do
2118+
Jido.Messaging.AgentDirectory.search_from_directory(runtime, query, opts)
2119+
else
2120+
{persistence, persistence_state} = Runtime.get_persistence(runtime)
2121+
persistence.directory_search(persistence_state, target, query, opts)
2122+
end
20312123
end
20322124

20332125
@doc "Start (or resume) an onboarding flow."

0 commit comments

Comments
 (0)