@passioncode-ai/fabric-agent-adapter 0.4.2 → 0.5.0
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/CHANGELOG.md +70 -0
- package/LICENSE +150 -17
- package/README.md +44 -6
- package/SECURITY.md +2 -3
- package/bin/fabric-agent-adapter.js +1 -1
- package/package.json +5 -5
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +4 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +25 -6
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +53 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +15 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +242 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +23 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +77 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +174 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +188 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +424 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +116 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +22 -5
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -46,6 +46,8 @@ _INSTANCE = re.compile(r"^[a-z][a-z0-9-]{0,31}$")
|
|
|
46
46
|
_KIND = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){0,5}$")
|
|
47
47
|
_ORIGIN = re.compile(r"^http://127\.0\.0\.1:([0-9]{3,5})$")
|
|
48
48
|
_CODE = re.compile(r"^[A-Za-z0-9_-]{16,256}$")
|
|
49
|
+
_TRACE_ID = re.compile(r"^(?!0{32}$)[0-9a-f]{32}$")
|
|
50
|
+
_SPAN_ID = re.compile(r"^(?!0{16}$)[0-9a-f]{16}$")
|
|
49
51
|
|
|
50
52
|
|
|
51
53
|
class ServiceError(Exception):
|
|
@@ -404,8 +406,12 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
|
|
|
404
406
|
# --- events ------------------------------------------------------------------
|
|
405
407
|
|
|
406
408
|
def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subject: Optional[Dict[str, str]] = None,
|
|
407
|
-
link: Optional[str] = None, notify: bool = False
|
|
408
|
-
|
|
409
|
+
link: Optional[str] = None, notify: bool = False, trace_id: Optional[str] = None,
|
|
410
|
+
span_id: Optional[str] = None) -> Dict[str, Any]:
|
|
411
|
+
"""One activity event: one sentence a person reads, never a machine id.
|
|
412
|
+
|
|
413
|
+
An event about traced work carries its trace as a pair, `trace_id` and `span_id`
|
|
414
|
+
(fabric-interop/0.1 C3.4 c); fabric_interop.trace_ids(traceparent) gives both."""
|
|
409
415
|
if level not in LEVELS:
|
|
410
416
|
raise ServiceError("level must be one of %s." % ", ".join(LEVELS))
|
|
411
417
|
if not _KIND.match(kind):
|
|
@@ -415,6 +421,10 @@ def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subj
|
|
|
415
421
|
raise ServiceError("An event needs a sentence.")
|
|
416
422
|
if link is not None and (not link.startswith("/") or link.startswith("//")):
|
|
417
423
|
raise ServiceError("link must be a path on this service.")
|
|
424
|
+
if (trace_id is None) != (span_id is None):
|
|
425
|
+
raise ServiceError("An event carries traceId and spanId together, or neither.")
|
|
426
|
+
if trace_id is not None and not (_TRACE_ID.match(trace_id) and _SPAN_ID.match(str(span_id))):
|
|
427
|
+
raise ServiceError("traceId is 32 and spanId 16 lowercase hex characters, not all zeros.")
|
|
418
428
|
event: Dict[str, Any] = {"id": str(event_id), "at": at, "kind": kind, "level": level, "text": text[:500]}
|
|
419
429
|
if subject:
|
|
420
430
|
event["subject"] = subject
|
|
@@ -422,6 +432,8 @@ def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subj
|
|
|
422
432
|
event["link"] = link
|
|
423
433
|
if notify:
|
|
424
434
|
event["notify"] = True
|
|
435
|
+
if trace_id is not None:
|
|
436
|
+
event["traceId"], event["spanId"] = trace_id, span_id
|
|
425
437
|
return event
|
|
426
438
|
|
|
427
439
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
It is the worked example the skill points at, the target check_service.py tests
|
|
5
5
|
itself against, and the fixture Fabric Dashboards runs its end-to-end tests on.
|
|
6
|
+
Over MCP (fabric-interop/0.1) it serves two capabilities: `sample.echo` answers at
|
|
7
|
+
once, and `sample.draft` is a job that stops for a titled choice before it completes.
|
|
8
|
+
`register` also writes the service's manifest, which names this descriptor.
|
|
6
9
|
|
|
7
10
|
sample_service.py serve --port 47190 --data-dir DIR [--id sample] [--instance default]
|
|
8
11
|
sample_service.py register --port 47190 --data-dir DIR [--services-dir DIR]
|
|
@@ -11,6 +14,7 @@ itself against, and the fixture Fabric Dashboards runs its end-to-end tests on.
|
|
|
11
14
|
from __future__ import annotations
|
|
12
15
|
|
|
13
16
|
import argparse
|
|
17
|
+
import hashlib
|
|
14
18
|
import html
|
|
15
19
|
import json
|
|
16
20
|
import os
|
|
@@ -24,6 +28,7 @@ from urllib.parse import parse_qs, urlparse
|
|
|
24
28
|
|
|
25
29
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
26
30
|
import fabric_service as fs # noqa: E402
|
|
31
|
+
import fabric_interop as fi # noqa: E402
|
|
27
32
|
|
|
28
33
|
VERSION = "0.1.0"
|
|
29
34
|
PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
@@ -32,6 +37,24 @@ PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
|
32
37
|
</head><body><h1>{name}</h1><p>Status: {status}</p><ul>{rows}</ul></body></html>"""
|
|
33
38
|
|
|
34
39
|
|
|
40
|
+
def _schema(name: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
41
|
+
return {"$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "urn:fabric:schema:%s" % name, **body}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
TEXT = {"type": "object", "additionalProperties": False, "properties": {"text": {"type": "string", "maxLength": 2000}}, "required": ["text"]}
|
|
45
|
+
CAPABILITIES = [
|
|
46
|
+
{"name": "sample.echo", "effect": "none", "idempotency": "required", "job": False,
|
|
47
|
+
"description": "Returns the text it was given.",
|
|
48
|
+
"input": _schema("sample.echo-input", TEXT), "output": _schema("sample.echo-output", TEXT)},
|
|
49
|
+
{"name": "sample.draft", "effect": "draft", "idempotency": "none", "job": True,
|
|
50
|
+
"description": "Drafts a short note on a topic; asks which title to use.",
|
|
51
|
+
"input": _schema("sample.draft-input", {"type": "object", "additionalProperties": False,
|
|
52
|
+
"properties": {"topic": {"type": "string", "minLength": 1, "maxLength": 200}}, "required": ["topic"]}),
|
|
53
|
+
"output": _schema("sample.draft-output", {"type": "object", "additionalProperties": False,
|
|
54
|
+
"properties": {"title": {"type": "string"}, "body": {"type": "string"}}, "required": ["title", "body"]})},
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
35
58
|
class Service:
|
|
36
59
|
def __init__(self, args: argparse.Namespace):
|
|
37
60
|
self.id = args.id
|
|
@@ -50,14 +73,57 @@ class Service:
|
|
|
50
73
|
self.token = fs.ensure_token(self.token_file)
|
|
51
74
|
self.log = fs.JsonlEventLog(self.data / "events.jsonl")
|
|
52
75
|
self.codes = fs.LoginCodes(self.data / "auth")
|
|
76
|
+
self.jobs = fi.JobStore(self.data / "jobs")
|
|
77
|
+
self.mcp = fi.McpToolServer(self.id, VERSION, jobs=self.jobs, on_job_started=self.draft_started, on_input=self.draft_answered)
|
|
78
|
+
for cap in CAPABILITIES:
|
|
79
|
+
handler = self.echo if cap["name"] == "sample.echo" else self.draft
|
|
80
|
+
self.mcp.add_tool(fi.tool_for_capability(cap, cap["input"], cap["output"]), handler)
|
|
53
81
|
self.log.append("service.started", "info", "%s started on build %s." % (self.name, self.build["commit"]))
|
|
54
82
|
|
|
83
|
+
# --- fabric-interop/0.1: the two capabilities ------------------------------------
|
|
84
|
+
def echo(self, args: Dict[str, Any], ctx: fi.CallContext) -> Dict[str, Any]:
|
|
85
|
+
if not isinstance(args.get("text"), str):
|
|
86
|
+
raise fi.InteropError("sample.echo needs text.")
|
|
87
|
+
return {"text": args["text"]}
|
|
88
|
+
|
|
89
|
+
def draft(self, args: Dict[str, Any], ctx: fi.CallContext) -> Dict[str, Any]:
|
|
90
|
+
if not isinstance(args.get("topic"), str) or not args["topic"].strip():
|
|
91
|
+
raise fi.InteropError("sample.draft needs a topic.")
|
|
92
|
+
return ctx.start_job(poll_interval_ms=1000)
|
|
93
|
+
|
|
94
|
+
def draft_started(self, job_id: str, ctx: fi.CallContext) -> None:
|
|
95
|
+
topic = str(ctx.arguments.get("topic"))
|
|
96
|
+
titles = [("plain", "About %s" % topic), ("question", "What is %s?" % topic)]
|
|
97
|
+
self.jobs.request_input(job_id, {"title_choice": fi.choice_request("Pick the title for the note.", "title", titles, "Title")},
|
|
98
|
+
"Two titles are ready.")
|
|
99
|
+
self.log.append("job.awaiting_choice", "notice", "A note about %s is waiting for you to pick its title." % topic,
|
|
100
|
+
notify=True, **fi.trace_ids(ctx.traceparent))
|
|
101
|
+
|
|
102
|
+
def draft_answered(self, job_id: str, answers: Dict[str, Any], ctx: fi.CallContext) -> None:
|
|
103
|
+
answer = answers.get("title_choice", {})
|
|
104
|
+
topic = str(ctx.arguments.get("topic"))
|
|
105
|
+
if answer.get("action") != "accept":
|
|
106
|
+
self.jobs.cancel(job_id)
|
|
107
|
+
self.log.append("job.cancelled", "info", "The note about %s was dropped: no title was chosen." % topic, **fi.trace_ids(ctx.traceparent))
|
|
108
|
+
return
|
|
109
|
+
choice = str((answer.get("content") or {}).get("title"))
|
|
110
|
+
title = "About %s" % topic if choice == "plain" else "What is %s?" % topic
|
|
111
|
+
body = "%s. This sample note was written by the sample service." % title
|
|
112
|
+
self.jobs.complete(job_id, fi.result_envelope(
|
|
113
|
+
done=[{"claimId": "NOTE", "statement": "A note titled %s was drafted." % title}], proof=[],
|
|
114
|
+
scope={"project": "urn:fabric:project:sample", "run": "urn:fabric:run:%s" % job_id, "node": "urn:fabric:node:draft",
|
|
115
|
+
"binding": {"id": "urn:fabric:binding:sample.draft", "revision": 1, "contentHash": "sha256:" + "0" * 64}, "writeScopes": []},
|
|
116
|
+
not_verified=[{"claim": "NOTE", "reason": "no checker has read the note"}],
|
|
117
|
+
output={"title": title, "body": body}, usage={"inputTokens": 0, "outputTokens": 0, "wallMs": 1}))
|
|
118
|
+
self.log.append("job.completed", "info", "The note %s is drafted." % title, **fi.trace_ids(ctx.traceparent))
|
|
119
|
+
|
|
55
120
|
def well_known(self) -> Dict[str, Any]:
|
|
56
121
|
return fs.build_well_known(
|
|
57
122
|
service_id=self.id, instance=self.instance, name=self.name, version=VERSION, build=self.build,
|
|
58
123
|
started_at=self.started_at, status="ready", degraded=self.degraded,
|
|
59
124
|
summary=[{"label": "Events", "value": len(self.log.fetch(None, fs.EVENTS_MAX_LIMIT))}],
|
|
60
|
-
surfaces={"dashboard": {"path": "/", "login": True}, "events": {"path": "/fabric/v1/events"}
|
|
125
|
+
surfaces={"dashboard": {"path": "/", "login": True}, "events": {"path": "/fabric/v1/events"},
|
|
126
|
+
"mcp": {"path": "/mcp", "transport": "streamable-http", "capabilities": [c["name"] for c in CAPABILITIES]}},
|
|
61
127
|
)
|
|
62
128
|
|
|
63
129
|
|
|
@@ -137,6 +203,16 @@ def make_handler(svc: Service):
|
|
|
137
203
|
if not self._guard():
|
|
138
204
|
return
|
|
139
205
|
url = urlparse(self.path)
|
|
206
|
+
if url.path == "/mcp":
|
|
207
|
+
if not self._token_ok():
|
|
208
|
+
return
|
|
209
|
+
length = min(int(self.headers.get("Content-Length") or 0), 1048576)
|
|
210
|
+
try:
|
|
211
|
+
message = json.loads(self.rfile.read(length) or b"null")
|
|
212
|
+
except ValueError:
|
|
213
|
+
return self._send(400, {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error."}})
|
|
214
|
+
response = svc.mcp.handle(message)
|
|
215
|
+
return self._send(202) if response is None else self._send(200, response)
|
|
140
216
|
if url.path == "/fabric/v1/login-code":
|
|
141
217
|
if not self._token_ok():
|
|
142
218
|
return
|
|
@@ -180,8 +256,46 @@ def serve(args: argparse.Namespace) -> int:
|
|
|
180
256
|
return 0
|
|
181
257
|
|
|
182
258
|
|
|
259
|
+
def write_manifest(args: argparse.Namespace, data: Path) -> Path:
|
|
260
|
+
"""The provider manifest for this service, naming its descriptor (G-07), with its schemas beside it."""
|
|
261
|
+
schemas = data / "fabric" / "schemas"
|
|
262
|
+
for cap in CAPABILITIES:
|
|
263
|
+
for side in ("input", "output"):
|
|
264
|
+
fs.atomic_write(schemas / ("%s-%s.schema.json" % (cap["name"], side)),
|
|
265
|
+
(json.dumps(cap[side], indent=2) + "\n").encode(), 0o600)
|
|
266
|
+
origin = "http://127.0.0.1:%d" % args.port
|
|
267
|
+
capabilities = []
|
|
268
|
+
for cap in CAPABILITIES:
|
|
269
|
+
capabilities.append({
|
|
270
|
+
"id": "urn:fabric:capability:%s" % cap["name"], "name": cap["name"], "description": cap["description"],
|
|
271
|
+
"inputSchema": cap["input"]["$id"], "outputSchema": cap["output"]["$id"],
|
|
272
|
+
"effect": cap["effect"], "idempotency": cap["idempotency"], "dataClasses": ["public"],
|
|
273
|
+
"profile": {"kind": "mcp", "protocolRevision": fi.MCP_REVISION,
|
|
274
|
+
"connection": {"mode": "streamable-http", "url": origin + "/mcp"},
|
|
275
|
+
"requiredFeatures": ["tool:%s" % cap["name"]],
|
|
276
|
+
"probes": [{"id": "%s-shape" % cap["name"].replace(".", "-"), "inputFixture": "urn:fabric:fixture:%s" % cap["name"],
|
|
277
|
+
"outputSchema": cap["output"]["$id"], "timeoutMs": 5000, "sideEffectCeiling": "none",
|
|
278
|
+
"assertions": ["returns a value valid against its output schema"]}]},
|
|
279
|
+
"extensions": {fi.EXTENSION_KEY: {"job": cap["job"]}},
|
|
280
|
+
})
|
|
281
|
+
digest = hashlib.sha256(json.dumps(capabilities, sort_keys=True).encode()).hexdigest()
|
|
282
|
+
subject = "urn:fabric:provider:%s" % args.id
|
|
283
|
+
manifest = {
|
|
284
|
+
"contractVersion": "0.1.0",
|
|
285
|
+
"provider": {"id": subject, "revision": 1, "contentHash": "sha256:" + digest, "createdAt": fs.now_iso(),
|
|
286
|
+
"createdBy": "urn:fabric:adapter:sample-service", "name": args.name,
|
|
287
|
+
"identity": {"subject": subject, "method": "local-install"}, "supportedContractVersions": ["0.1.0"],
|
|
288
|
+
"extensions": {fs.EXTENSION_KEY: {"descriptor": "%s.%s" % (args.id, args.instance)}}},
|
|
289
|
+
"capabilities": capabilities,
|
|
290
|
+
}
|
|
291
|
+
path = data / "fabric-agent.json"
|
|
292
|
+
fs.atomic_write(path, (json.dumps(manifest, indent=2) + "\n").encode(), 0o600)
|
|
293
|
+
return path
|
|
294
|
+
|
|
295
|
+
|
|
183
296
|
def register(args: argparse.Namespace) -> int:
|
|
184
297
|
data = Path(args.data_dir)
|
|
298
|
+
manifest = write_manifest(args, data)
|
|
185
299
|
descriptor = {
|
|
186
300
|
"protocol": fs.PROTOCOL, "id": args.id, "instance": args.instance, "name": args.name,
|
|
187
301
|
"summary": "Sample fabric-service/0.1 service.",
|
|
@@ -190,6 +304,7 @@ def register(args: argparse.Namespace) -> int:
|
|
|
190
304
|
"lifecycle": {"manager": "launchd", "label": args.label, "plist": args.plist} if args.label
|
|
191
305
|
else {"manager": "none"},
|
|
192
306
|
"paths": {"data": str(data), "logs": []},
|
|
307
|
+
"fabricManifest": str(manifest),
|
|
193
308
|
"installedAt": fs.now_iso(), "installedBy": "sample_service.py register",
|
|
194
309
|
}
|
|
195
310
|
try:
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: creating-fabric-agents
|
|
3
3
|
description: Use when designing and building a NEW agent or provider that must be Fabric-compatible from its first commit — «создай агента, совместимого с фабрикой Passion Code», «новый агент под Fabric», "create a fabric-compatible agent", "build a new Fabric provider", "fabric-ready agent from scratch". Runs the intake grill (capability, named consumer, workflow-or-agent, MCP/A2A/local-runner profile, effect declarations), distils source projects into a knowledge pack whose recorded failures become planted eval fixtures, scaffolds the pinned contract bundle, and sets the two-clock eval expectation with the conformance report. NOT for adapting an existing project (use adapting-projects-to-fabric), building the Fabric host or orchestrator, or generic agent design where Fabric compatibility is not part of the request.
|
|
4
|
-
license:
|
|
4
|
+
license: PolyForm-Noncommercial-1.0.0 OR LicenseRef-PolyForm-Internal-Use-1.0.0
|
|
5
5
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned private fabric-agent-contract checkout. Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
|
|
6
6
|
metadata:
|
|
7
|
-
author:
|
|
8
|
-
version: "0.
|
|
7
|
+
author: PassionCode.ai
|
|
8
|
+
version: "0.5.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
10
|
+
contract-commit: "a22dea359ba04b8fe549abe81a5131552cb90eff"
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
# Creating Fabric-compatible agents
|
|
@@ -30,6 +30,17 @@ Use this skill to design and build a new provider. Do not use it to:
|
|
|
30
30
|
- claim admission: the deliverable is an admission-ready provider bundle, never a
|
|
31
31
|
connected provider.
|
|
32
32
|
|
|
33
|
+
## Handing off to neighbouring skills
|
|
34
|
+
|
|
35
|
+
- The agent's own loop — tool calls, routing and fallback, memory, sub-agents — is
|
|
36
|
+
designed with `agent-orchestrator` (agent-stack).
|
|
37
|
+
- Its eval suite beyond the step-2 observables — trajectories, judges, regression
|
|
38
|
+
fixtures from traces — is built with `agent-evals` (agent-stack).
|
|
39
|
+
- Packaging the agent as an Agent Skill or Claude Code plugin goes through `make-skill`.
|
|
40
|
+
|
|
41
|
+
When one of them is not installed, do that part by hand against its own documentation
|
|
42
|
+
and say in the report which skill was missing.
|
|
43
|
+
|
|
33
44
|
## Step 0 — the intake grill
|
|
34
45
|
|
|
35
46
|
No file is created until every row has an answer. An answer of "later" fails the grill.
|
|
@@ -82,7 +93,7 @@ python3 <plugin-dir>/skills/adapting-projects-to-fabric/scripts/adapt_project.py
|
|
|
82
93
|
--schema-base <immutable-base-uri>
|
|
83
94
|
```
|
|
84
95
|
|
|
85
|
-
Pin exactly contract `0.1.0` at commit `
|
|
96
|
+
Pin exactly contract `0.1.0` at commit `a22dea359ba04b8fe549abe81a5131552cb90eff` and
|
|
86
97
|
read the pinned guide before implementing protocol details. If this skill is installed
|
|
87
98
|
without its sibling, the scaffolder is absent: create the bundle by hand from the pinned
|
|
88
99
|
contract's `docs/guides/connecting-compatible-agents.md` and mark the structural check
|
|
@@ -91,6 +102,12 @@ contract's `docs/guides/connecting-compatible-agents.md` and mark the structural
|
|
|
91
102
|
The generated placeholders are deliberate blockers; a bundle still containing one is not
|
|
92
103
|
ready for admission.
|
|
93
104
|
|
|
105
|
+
A new agent is called the `fabric-interop/0.1` way from its first version: add `--job`
|
|
106
|
+
for a capability whose work may outlive one request, serve each capability as the MCP
|
|
107
|
+
tool of its name, and carry the caller's trace (the `building-fabric-services` interop
|
|
108
|
+
reference). An agent that is not a service is announced with a provider entry
|
|
109
|
+
(`adapting-projects-to-fabric`, `scripts/fabric_provider.py`).
|
|
110
|
+
|
|
94
111
|
## Step 4 — implement against the observables
|
|
95
112
|
|
|
96
113
|
If the agent keeps running on the operator's computer — it answers other agents, runs
|